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%

Web app: home + 3D globe, satellites/launches, entities, stats/events, sources/admin/SEO; API fixes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent f4a43be

116 changed files +9,213 −55

added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
added apps/web/qa/entities.mjs +37 −0
@@ -0,0 +1,37 @@
1 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
2 +// The satelliteindex dev server port is read from .next/dev/lock (8310 may be taken by another project on this machine).
3 +import { readFileSync } from 'node:fs';
4 +let port = process.env.PORT ?? '8310';
5 +try { port = String(JSON.parse(readFileSync(new URL('../.next/dev/lock', import.meta.url), 'utf8')).port ?? port); } catch { /* fall back */ }
6 +const BASE = `http://localhost:${port}`;
7 +console.log(`QA against ${BASE}`);
8 +const PAGES = ['/constellations', '/constellations?sort=activity&service=navigation', '/constellation/starlink', '/constellation/gps', '/operators', '/operators?kind=agency&q=nasa', '/operator/spacex', '/operator/nasa', '/countries', '/countries?sort=debris', '/country/canada', '/country/china', '/constellation/does-not-exist'];
9 +const OUT = '/Users/simon-pierreboucher/Desktop/Projets/apps-web/satelliteindex/apps/web/qa/screens/entities';
10 +const browser = await chromium.launch();
11 +let failures = 0;
12 +for (const width of [390, 1440]) {
13 + const ctx = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, deviceScaleFactor: 1 });
14 + for (const p of PAGES) {
15 + const page = await ctx.newPage();
16 + const errors = [];
17 + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
18 + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
19 + const t0 = Date.now();
20 + const res = await page.goto(BASE + p, { waitUntil: 'networkidle', timeout: 120000 });
21 + const status = res?.status();
22 + const { sw, iw, h1 } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, iw: window.innerWidth, h1: document.querySelector('h1')?.textContent?.trim() }));
23 + const name = p.replace(/^\//, '').replace(/[\/?=&]+/g, '_') || 'root';
24 + await page.screenshot({ path: `${OUT}/${name}-${width}.png`, fullPage: true });
25 + const overflow = sw > iw;
26 + const expect404 = p.includes('does-not-exist');
27 + // A 404 document legitimately logs "Failed to load resource … 404" — that is the expected status, not a page error.
28 + const realErrors = expect404 ? errors.filter((e) => !/status of 404/.test(e)) : errors;
29 + const bad = overflow || realErrors.length || (!expect404 && status !== 200) || (expect404 && status !== 404);
30 + if (bad) failures++;
31 + console.log(`${bad ? 'FAIL' : 'ok '} ${width}px ${p} status=${status} scrollWidth=${sw}/${iw} ${Date.now() - t0}ms h1="${h1}"${errors.length ? '\n console: ' + errors.slice(0, 3).join(' | ') : ''}`);
32 + await page.close();
33 + }
34 + await ctx.close();
35 +}
36 +await browser.close();
37 +process.exit(failures ? 1 : 0);
added apps/web/qa/meta-screens.mjs +82 −0
@@ -0,0 +1,82 @@
1 +// QA for the meta/admin surface: screenshots at 390 and 1440, console errors, horizontal overflow, admin auth flow.
2 +// Usage: node qa/meta-screens.mjs [baseUrl] (default http://localhost:8319)
3 +import { mkdirSync } from 'node:fs';
4 +import path from 'node:path';
5 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
6 +
7 +const BASE = process.argv[2] ?? 'http://localhost:8319';
8 +const OUT = path.resolve(import.meta.dirname, 'screens/meta');
9 +mkdirSync(OUT, { recursive: true });
10 +
11 +const PUBLIC = ['/sources', '/methodology', '/status', '/status/data', '/developers', '/about', '/privacy', '/terms', '/admin/login'];
12 +const ADMIN = ['/admin', '/admin/connectors/celestrak_gp', '/admin/raw', '/admin/data-quality', '/admin/entity-resolution', '/admin/costs'];
13 +const VIEWPORTS = [
14 + { name: '390', width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 },
15 + { name: '1440', width: 1440, height: 900, isMobile: false, deviceScaleFactor: 1 },
16 +];
17 +
18 +const browser = await chromium.launch();
19 +const problems = [];
20 +
21 +async function audit(page, route, tag) {
22 + const errors = [];
23 + const onConsole = (m) => m.type() === 'error' && errors.push(m.text());
24 + const onPageError = (e) => errors.push(`pageerror: ${e.message}`);
25 + page.on('console', onConsole);
26 + page.on('pageerror', onPageError);
27 + const res = await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle', timeout: 120_000 });
28 + await page.waitForTimeout(400);
29 + const { sw, iw, title } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, iw: window.innerWidth, title: document.title }));
30 + const file = `${route.replace(/^\//, '').replace(/[\/?=&]+/g, '_') || 'home'}-${tag}.png`;
31 + await page.screenshot({ path: path.join(OUT, file), fullPage: true });
32 + page.off('console', onConsole);
33 + page.off('pageerror', onPageError);
34 + const status = res?.status();
35 + const line = `${String(status).padEnd(4)} ${tag.padEnd(5)} ${route.padEnd(36)} sw=${sw} iw=${iw} ${title}`;
36 + console.log(line);
37 + if (status !== 200) problems.push(`${route}@${tag}: HTTP ${status}`);
38 + // Mobile emulation zooms out when content is wider than the viewport, inflating innerWidth: compare with the requested width too.
39 + const want = Number(tag);
40 + if (sw > iw || iw !== want || sw > want) problems.push(`${route}@${tag}: horizontal overflow scrollWidth=${sw} innerWidth=${iw} viewport=${want}`);
41 + const realErrors = errors.filter((e) => !/favicon|apple-icon/.test(e));
42 + if (realErrors.length) problems.push(`${route}@${tag}: console errors\n ${realErrors.join('\n ')}`);
43 +}
44 +
45 +for (const vp of VIEWPORTS) {
46 + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, isMobile: vp.isMobile, deviceScaleFactor: vp.deviceScaleFactor, hasTouch: vp.isMobile });
47 + const page = await ctx.newPage();
48 + for (const r of PUBLIC) await audit(page, r, vp.name);
49 +
50 + // Unauthenticated /admin must redirect to /admin/login.
51 + const anon = await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
52 + const landed = new URL(page.url()).pathname;
53 + console.log(`redirect check: /admin -> ${landed} (${anon?.status()})`);
54 + if (landed !== '/admin/login') problems.push(`anonymous /admin landed on ${landed}`);
55 +
56 + // Login with the dev token, then audit admin pages.
57 + await page.goto(`${BASE}/admin/login?next=%2Fadmin%2Fcosts`, { waitUntil: 'networkidle' });
58 + await page.fill('input[name=token]', 'wrong-token');
59 + await page.click('button[type=submit]');
60 + await page.waitForURL(/error=invalid/);
61 + console.log('wrong token -> error shown');
62 + await page.fill('input[name=token]', 'dev-admin-token');
63 + await page.click('button[type=submit]');
64 + await page.waitForURL((u) => u.pathname === '/admin/costs', { timeout: 60_000 });
65 + console.log(`login ok -> ${new URL(page.url()).pathname}`);
66 + const cookies = await ctx.cookies();
67 + const c = cookies.find((k) => k.name === 'si_admin');
68 + if (!c || !c.httpOnly) problems.push('si_admin cookie missing or not httpOnly');
69 + for (const r of ADMIN) await audit(page, r, vp.name);
70 +
71 + // Sign out clears the session.
72 + await page.click('nav[aria-label=Admin] button[type=submit]');
73 + await page.waitForURL(/\/admin\/login/);
74 + const after = await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
75 + if (new URL(page.url()).pathname !== '/admin/login') problems.push('after logout /admin did not redirect');
76 + console.log(`logout ok -> ${new URL(page.url()).pathname} (${after?.status()})`);
77 + await ctx.close();
78 +}
79 +await browser.close();
80 +
81 +console.log('\n' + (problems.length ? `PROBLEMS (${problems.length}):\n- ${problems.join('\n- ')}` : 'OK: no overflow, no console errors, auth flow verified'));
82 +process.exit(problems.length ? 1 : 0);
added apps/web/qa/satellite-qa.mjs +70 −0
@@ -0,0 +1,70 @@
1 +/**
2 + * QA for the satellite / launches pages: screenshots at 390 and 1440, console errors, horizontal overflow,
3 + * DOM-order = visual-order check on the satellite detail page.
4 + * node qa/satellite-qa.mjs [baseUrl]
5 + */
6 +import { mkdirSync } from 'node:fs';
7 +import path from 'node:path';
8 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
9 +
10 +const BASE = process.argv[2] ?? 'http://localhost:8319';
11 +const OUT = path.resolve(import.meta.dirname, 'screens/satellite');
12 +mkdirSync(OUT, { recursive: true });
13 +
14 +const PAGES = [
15 + ['satellite-iss', '/satellite/iss-zarya-25544'],
16 + ['satellite-starlink', '/satellite/starlink-38436-100641'],
17 + ['satellite-sputnik', '/satellite/sputnik-1-2'],
18 + ['satellites', '/satellites'],
19 + ['satellites-starlink-active', '/satellites?constellation=starlink&status=ACTIVE'],
20 + ['satellites-empty', '/satellites?status=PLANNED&object_type=DEBRIS'],
21 + ['launches', '/launches'],
22 + ['launches-2026', '/launches?year=2026'],
23 + ['launch-1998-067', '/launch/1998-067'],
24 + ['launch-sites', '/launch-sites'],
25 + ['launch-site-baikonur', '/launch-sites/baikonur-cosmodrome-tyuratam'],
26 +];
27 +const VIEWPORTS = [
28 + ['390', { width: 390, height: 844 }],
29 + ['1440', { width: 1440, height: 900 }],
30 +];
31 +
32 +const browser = await chromium.launch();
33 +let failures = 0;
34 +for (const [vpName, viewport] of VIEWPORTS) {
35 + const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme: 'dark' });
36 + for (const [name, url] of PAGES) {
37 + const page = await ctx.newPage();
38 + const errors = [];
39 + page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
40 + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
41 + const res = await page.goto(BASE + url, { waitUntil: 'networkidle', timeout: 90_000 });
42 + await page.waitForTimeout(1200);
43 + const metrics = await page.evaluate(() => {
44 + const de = document.documentElement;
45 + const overflow = de.scrollWidth - de.clientWidth;
46 + const wide = [...document.querySelectorAll('body *')].filter((el) => el.getBoundingClientRect().right > de.clientWidth + 1).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 2).join('.')}`);
47 + // DOM order == visual order for the detail-page sections
48 + const ids = ['live', 'orbit', 'mission', 'ownership', 'launch', 'history', 'registration', 'sources', 'events', 'related', 'identifiers'];
49 + const tops = ids.map((id) => document.getElementById(id)?.getBoundingClientRect().top).filter((t) => t !== undefined);
50 + const ordered = tops.every((t, i) => i === 0 || t >= tops[i - 1]);
51 + const h1 = document.querySelector('h1');
52 + const h1Top = h1 ? h1.getBoundingClientRect().top + window.scrollY : null;
53 + const firstSectionTop = tops[0] !== undefined ? tops[0] + window.scrollY : null;
54 + const usesOrder = [...document.querySelectorAll('main *')].some((el) => getComputedStyle(el).order !== '0');
55 + return { overflow, wide, ordered, heroFirst: h1Top === null || firstSectionTop === null || h1Top < firstSectionTop, usesOrder, scrollY: window.scrollY, title: document.title };
56 + });
57 + const file = path.join(OUT, `${name}-${vpName}.png`);
58 + await page.screenshot({ path: file, fullPage: true });
59 + const bad = (res?.status() ?? 0) >= 400 || errors.length || metrics.overflow > 0 || !metrics.ordered || !metrics.heroFirst || metrics.usesOrder || metrics.scrollY !== 0;
60 + if (bad) failures++;
61 + console.log(`${bad ? 'FAIL' : ' ok '} ${vpName.padEnd(4)} ${url.padEnd(52)} ${res?.status()} overflow=${metrics.overflow} order=${metrics.ordered ? 'ok' : 'BAD'} hero=${metrics.heroFirst ? 'first' : 'NOT-FIRST'} cssOrder=${metrics.usesOrder ? 'USED' : 'none'} errors=${errors.length}`);
62 + if (errors.length) console.log(' ', errors.slice(0, 3).join('\n '));
63 + if (metrics.wide.length) console.log(' wide:', metrics.wide.join(', '));
64 + await page.close();
65 + }
66 + await ctx.close();
67 +}
68 +await browser.close();
69 +console.log(failures ? `\n${failures} page(s) failed` : '\nall pages passed');
70 +process.exit(failures ? 1 : 0);
added apps/web/qa/screens.mjs +48 −0
@@ -0,0 +1,48 @@
1 +/**
2 + * Production QA sweep: every key route at 390 and 1440 px — HTTP status, console errors, horizontal overflow, screenshot.
3 + * Run: node qa/screens.mjs [BASE_URL] (default http://localhost:8321)
4 + */
5 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
6 +import { mkdirSync } from 'node:fs';
7 +
8 +const BASE = process.argv[2] ?? 'http://localhost:8321';
9 +const OUT = new URL('./screens/sweep/', import.meta.url).pathname;
10 +mkdirSync(OUT, { recursive: true });
11 +
12 +const PAGES = ['/', '/explore', '/search?q=starlink', '/satellites', '/satellites?constellation=starlink&status=ACTIVE', '/satellite/iss-zarya-25544',
13 + '/satellite/sputnik-1-2', '/constellations', '/constellation/starlink', '/operators', '/operator/spacex', '/countries', '/country/canada',
14 + '/launches', '/launch/1998-067', '/launch-sites', '/debris', '/reentries', '/events', '/stats', '/rankings?metric=operators', '/sources',
15 + '/methodology', '/status', '/developers', '/about', '/privacy', '/terms', '/admin', '/does-not-exist'];
16 +const WIDTHS = [390, 1440];
17 +const browser = await chromium.launch();
18 +let failures = 0;
19 +for (const width of WIDTHS) {
20 + const mobile = width < 768;
21 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile });
22 + const page = await ctx.newPage();
23 + for (const path of PAGES) {
24 + const errors = [];
25 + const onErr = (e) => errors.push(String(e));
26 + const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };
27 + page.on('pageerror', onErr);
28 + page.on('console', onCon);
29 + const t0 = Date.now();
30 + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
31 + await page.waitForTimeout(800);
32 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
33 + const expected = path === '/does-not-exist' ? 404 : path === '/admin' ? [200, 307] : 200;
34 + const status = res.status();
35 + const okStatus = Array.isArray(expected) ? expected.includes(status) : status === expected;
36 + const filtered = errors.filter((e) => !/WebGL|webgl|THREE\.WebGLRenderer|favicon/.test(e));
37 + const ok = okStatus && overflow <= 0 && filtered.length === 0;
38 + if (!ok) failures++;
39 + console.log(`${ok ? 'OK ' : 'FAIL'} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 120) : ''}`);
40 + await page.screenshot({ path: `${OUT}${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);
41 + page.off('pageerror', onErr);
42 + page.off('console', onCon);
43 + }
44 + await ctx.close();
45 +}
46 +await browser.close();
47 +console.log(failures ? `\n${failures} failure(s)` : '\nall pages OK');
48 +process.exit(failures ? 1 : 0);
added apps/web/src/app/about/page.tsx +69 −0
@@ -0,0 +1,69 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ATTRIBUTION, DISCLAIMER, MISSION } from '@/components/meta/legal';
4 +import { KeyValues, Prose } from '@/components/meta/prose';
5 +import { Container, PageHeader, Section } from '@/components/ui/section';
6 +import { AUTHOR, CONTACT_EMAIL, SITE_NAME, SITE_URL, routes } from '@/lib/site';
7 +
8 +export const metadata: Metadata = {
9 + title: 'About SatelliteIndex',
10 + description: 'What SatelliteIndex is, who builds it, what it is not, and where the data comes from.',
11 + alternates: { canonical: `${SITE_URL}/about` },
12 + openGraph: { title: 'About | SatelliteIndex', description: 'What SatelliteIndex is, who builds it and where the data comes from.', url: `${SITE_URL}/about` },
13 + twitter: { card: 'summary', title: 'About | SatelliteIndex', description: 'What SatelliteIndex is, who builds it and where the data comes from.' },
14 +};
15 +
16 +export default function AboutPage() {
17 + return (
18 + <Container>
19 + <PageHeader eyebrow="About" title="An index of everything in orbit" lede={MISSION} />
20 +
21 + <Section eyebrow="What it is" title="A canonical, source-attributed layer" className="pt-0">
22 + <Prose>
23 + <p>
24 + {SITE_NAME} keeps one canonical record per object in Earth orbit — payloads, rocket bodies, debris, stations — and links it to the entities around it: the constellation it belongs to, the operator and country responsible for it, the launch that put it there and the site it left from. Orbital element sets are appended, never overwritten, so every object carries its own history.
25 + </p>
26 + <p>
27 + On top of that canonical layer sit derived views: live SGP4 positions, orbit classes, mission types, constellation growth, orbital density by altitude, reentry timelines and rankings. Each derived value is labelled as such and documented in a versioned <Link href={routes.methodology()} className="link">methodology</Link>; each raw value keeps its provenance and is listed with its license on the <Link href={routes.sources()} className="link">sources</Link> page.
28 + </p>
29 + <p>
30 + The whole thing is exposed through a public JSON <Link href={routes.developers()} className="link">API</Link> that the website itself consumes — there is no hidden dataset behind the pages.
31 + </p>
32 + </Prose>
33 + </Section>
34 +
35 + <Section eyebrow="What it is not" title="Honest limits">
36 + <Prose>
37 + <ul>
38 + <li>Not a conjunction-assessment or collision-avoidance service. We publish orbital density as an informational count, never a probability of collision.</li>
39 + <li>Not a reentry-prediction service. Decay dates come from the catalog after the fact; low-perigee watch lists are exactly that — watch lists.</li>
40 + <li>Not authoritative on ownership or mission. Operator, country, mission type and constellation membership are resolved from owner codes, CelesTrak groups and documented name patterns, and can be wrong for individual objects.</li>
41 + <li>Not real-time. Element sets are refreshed on a schedule and positions are propagated from them; freshness is shown on every page and on <Link href={routes.status()} className="link">/status</Link>.</li>
42 + </ul>
43 + <p className="mt-4 text-ink-3">{DISCLAIMER}</p>
44 + </Prose>
45 + </Section>
46 +
47 + <Section eyebrow="Who" title="Built and operated by">
48 + <KeyValues
49 + rows={[
50 + { k: 'Author', v: AUTHOR },
51 + { k: 'Contact', v: <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a> },
52 + { k: 'Hosting', v: <>Self-hosted on <a href="https://www.maclustr.io" rel="noopener noreferrer" className="link">MacLustr</a>, a cluster of Apple silicon machines; public routes go through the MacLustr Tunnel.</> },
53 + { k: 'Stack', v: 'Python 3.12 · FastAPI · PostgreSQL · Redis · python-sgp4 · Next.js · Tailwind' },
54 + { k: 'Status', v: <>MVP. The API is free without keys for now (<Link href={routes.developers()} className="link">details</Link>).</> },
55 + ]}
56 + />
57 + </Section>
58 +
59 + <Section eyebrow="Sources" title="Attribution">
60 + <Prose>
61 + <p>{ATTRIBUTION}</p>
62 + <p>
63 + Read the <Link href={routes.terms()} className="link">terms</Link> and <Link href={routes.privacy()} className="link">privacy</Link> pages for the legal details; they are short.
64 + </p>
65 + </Prose>
66 + </Section>
67 + </Container>
68 + );
69 +}
added apps/web/src/app/admin/(protected)/connectors/[name]/page.tsx +97 −0
@@ -0,0 +1,97 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EnabledToggle, RunNowButton } from '@/components/admin/actions';
4 +import { AdminError, AdminHeader, JsonInline, RunsTable, SectionTitle } from '@/components/admin/ui';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { adminApi, safeAdmin } from '@/lib/admin-api';
7 +import { fmtAgo, fmtDateTime } from '@/lib/format';
8 +
9 +export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
10 + const { name } = await params;
11 + return { title: `${name} runs` };
12 +}
13 +
14 +export default async function AdminConnectorPage({ params, searchParams }: { params: Promise<{ name: string }>; searchParams: Promise<{ page?: string }> }) {
15 + const { name } = await params;
16 + const sp = await searchParams;
17 + const page = Math.max(1, Number(sp.page) || 1);
18 + const [runsRes, overviewRes] = await Promise.all([safeAdmin(adminApi.connectorRuns(name, page, 50)), safeAdmin(adminApi.overview())]);
19 + const connector = overviewRes.data?.data.connectors.find((c) => c.name === name) ?? null;
20 + const now = Date.now();
21 + return (
22 + <>
23 + <AdminHeader
24 + title={<span className="mono">{name}</span>}
25 + lede={connector ? `${connector.source_name} · ${connector.description ?? ''}` : 'Connector runs and errors'}
26 + action={
27 + <div className="flex flex-wrap items-center gap-2">
28 + <RunNowButton name={name} />
29 + {connector && <EnabledToggle name={name} enabled={connector.enabled} />}
30 + <Link href="/admin" className="text-sm text-accent hover:underline">
31 + ← Overview
32 + </Link>
33 + </div>
34 + }
35 + />
36 + {connector && (
37 + <dl className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
38 + <div>
39 + <dt className="eyebrow">Enabled</dt>
40 + <dd className={connector.enabled ? 'text-active' : 'text-warn'}>{connector.enabled ? 'yes' : 'no'}</dd>
41 + </div>
42 + <div>
43 + <dt className="eyebrow">Last success</dt>
44 + <dd title={fmtDateTime(connector.last_success_at)}>{fmtAgo(connector.last_success_at, now)}</dd>
45 + </div>
46 + <div>
47 + <dt className="eyebrow">Last attempt</dt>
48 + <dd title={fmtDateTime(connector.last_attempt_at)}>{fmtAgo(connector.last_attempt_at, now)}</dd>
49 + </div>
50 + <div>
51 + <dt className="eyebrow">Next run</dt>
52 + <dd>{fmtDateTime(connector.next_run_at)}</dd>
53 + </div>
54 + <div className="col-span-2 md:col-span-4">
55 + <dt className="eyebrow">Config</dt>
56 + <dd>
57 + <JsonInline value={connector.config} />
58 + </dd>
59 + </div>
60 + </dl>
61 + )}
62 +
63 + {runsRes.error !== null ? (
64 + <div className="mt-6">
65 + <AdminError message={runsRes.error} />
66 + </div>
67 + ) : (
68 + <>
69 + <SectionTitle>Runs</SectionTitle>
70 + <RunsTable runs={runsRes.data.data} now={now} showConnector={false} emptyLabel="This connector has never run" />
71 + <Pagination className="mt-4" page={runsRes.data.pagination.page} pages={runsRes.data.pagination.pages} total={runsRes.data.pagination.total} pageSize={runsRes.data.pagination.page_size} makeHref={(p) => `/admin/connectors/${encodeURIComponent(name)}?page=${p}`} />
72 +
73 + <SectionTitle>Recent errors</SectionTitle>
74 + {runsRes.data.errors.length === 0 ? (
75 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No errors recorded</p>
76 + ) : (
77 + <ul className="divide-y divide-rule border-y border-rule">
78 + {runsRes.data.errors.map((e) => (
79 + <li key={e.id} className="grid gap-1 py-2.5 text-sm sm:grid-cols-[160px_140px_minmax(0,1fr)] sm:gap-4">
80 + <span className="text-ink-2" title={fmtDateTime(e.occurred_at)}>
81 + {fmtAgo(e.occurred_at, now)}
82 + </span>
83 + <span className="mono text-xs text-danger">{e.error_type ?? 'Error'}</span>
84 + <span className="min-w-0">
85 + <span className="mono break-words text-xs text-ink">{e.message ?? '—'}</span>
86 + {e.run_id && <span className="mono ml-2 text-[11px] text-ink-3">{e.run_id}</span>}
87 + {e.context && <JsonInline value={e.context} className="mt-1" />}
88 + </span>
89 + </li>
90 + ))}
91 + </ul>
92 + )}
93 + </>
94 + )}
95 + </>
96 + );
97 +}
added apps/web/src/app/admin/(protected)/costs/page.tsx +131 −0
@@ -0,0 +1,131 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminError, AdminHeader, SectionTitle, Tile, fmtBytes } from '@/components/admin/ui';
4 +import { fmtDuration } from '@/components/meta/connectors-table';
5 +import { adminApi, safeAdmin } from '@/lib/admin-api';
6 +import { fmtDate, fmtInt, num } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'Costs' };
9 +
10 +export default async function AdminCostsPage() {
11 + const res = await safeAdmin(adminApi.costs());
12 + if (res.error !== null) {
13 + return (
14 + <>
15 + <AdminHeader title="Costs" />
16 + <AdminError message={res.error} />
17 + </>
18 + );
19 + }
20 + const d = res.data.data;
21 + const totalRuns = d.per_connector.reduce((a, c) => a + (num(c.runs_30d) ?? 0), 0);
22 + const totalMs = d.per_connector.reduce((a, c) => a + (num(c.total_ms) ?? 0), 0);
23 + const totalFailed = d.per_connector.reduce((a, c) => a + (num(c.failed) ?? 0), 0);
24 + return (
25 + <>
26 + <AdminHeader title="Costs" lede="Compute (connector run time), storage and third-party provider usage over the last 30 days. All current sources are free; no paid provider is enabled." />
27 +
28 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
29 + <Tile label="Runs · 30 d" value={fmtInt(totalRuns)} />
30 + <Tile label="Run time · 30 d" value={fmtDuration(totalMs)} />
31 + <Tile label="Failed runs" value={fmtInt(totalFailed)} tone={totalFailed > 0 ? 'warn' : undefined} />
32 + <Tile label="Database" value={d.storage.database} />
33 + </div>
34 +
35 + <SectionTitle>Per connector · last 30 days</SectionTitle>
36 + {d.per_connector.length === 0 ? (
37 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No runs in the last 30 days</p>
38 + ) : (
39 + <div className="overflow-x-auto">
40 + <table className="data-table stack md:min-w-[720px]">
41 + <thead>
42 + <tr>
43 + <th>Connector</th>
44 + <th className="num">Runs</th>
45 + <th className="num">Records</th>
46 + <th className="num">Total time</th>
47 + <th className="num">Avg / run</th>
48 + <th className="num">Failed</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {d.per_connector.map((c) => {
53 + const runs = num(c.runs_30d) ?? 0;
54 + const ms = num(c.total_ms) ?? 0;
55 + const failed = num(c.failed) ?? 0;
56 + return (
57 + <tr key={c.connector_name}>
58 + <td className="primary">
59 + <Link href={`/admin/connectors/${encodeURIComponent(c.connector_name)}`} className="mono text-sm text-ink hover:text-accent">
60 + {c.connector_name}
61 + </Link>
62 + </td>
63 + <td data-label="Runs" className="num tnum text-sm">
64 + {fmtInt(runs)}
65 + </td>
66 + <td data-label="Records" className="num tnum text-sm text-ink-2">
67 + {fmtInt(c.records)}
68 + </td>
69 + <td data-label="Total time" className="num tnum text-sm text-ink-2">
70 + {fmtDuration(ms)}
71 + </td>
72 + <td data-label="Avg / run" className="num tnum text-sm text-ink-2">
73 + {runs > 0 ? fmtDuration(ms / runs) : '—'}
74 + </td>
75 + <td data-label="Failed" className={`num tnum text-sm ${failed > 0 ? 'text-warn' : 'text-ink-2'}`}>
76 + {fmtInt(failed)}
77 + </td>
78 + </tr>
79 + );
80 + })}
81 + </tbody>
82 + </table>
83 + </div>
84 + )}
85 +
86 + <SectionTitle>Storage</SectionTitle>
87 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
88 + <Tile label="Database" value={d.storage.database} hint="pg_database_size" />
89 + <Tile label="orbital_elements" value={d.storage.orbital_elements} hint="append-only history" />
90 + <Tile label="satellites" value={d.storage.satellites} hint="canonical table + indexes" />
91 + <Tile label="Raw payloads" value={d.storage.raw_uncompressed} hint="uncompressed bytes (stored gzip)" />
92 + </div>
93 +
94 + <SectionTitle>Raw snapshot growth · 30 days</SectionTitle>
95 + {d.raw_growth.length === 0 ? (
96 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No snapshots in the last 30 days</p>
97 + ) : (
98 + <div className="overflow-x-auto">
99 + <table className="data-table md:max-w-md">
100 + <thead>
101 + <tr>
102 + <th>Day</th>
103 + <th className="num">Snapshots</th>
104 + <th className="num">Bytes</th>
105 + </tr>
106 + </thead>
107 + <tbody>
108 + {d.raw_growth.map((g) => (
109 + <tr key={g.day}>
110 + <td className="text-sm">{fmtDate(g.day)}</td>
111 + <td className="num tnum text-sm">{fmtInt(g.snapshots)}</td>
112 + <td className="num tnum text-sm text-ink-2">{fmtBytes(g.bytes)}</td>
113 + </tr>
114 + ))}
115 + </tbody>
116 + </table>
117 + </div>
118 + )}
119 +
120 + <SectionTitle>Paid providers</SectionTitle>
121 + <ul className="divide-y divide-rule border-y border-rule">
122 + {Object.entries(d.paid_providers).map(([k, v]) => (
123 + <li key={k} className="flex items-center justify-between gap-3 py-2.5 text-sm">
124 + <span className="mono text-ink">{k}</span>
125 + <span className={v === 'not enabled' ? 'text-ink-3' : 'text-warn'}>{v}</span>
126 + </li>
127 + ))}
128 + </ul>
129 + </>
130 + );
131 +}
added apps/web/src/app/admin/(protected)/data-quality/page.tsx +113 −0
@@ -0,0 +1,113 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminError, AdminHeader, SectionTitle, Tile } from '@/components/admin/ui';
4 +import { Pagination } from '@/components/ui/pagination';
5 +import { adminApi, safeAdmin } from '@/lib/admin-api';
6 +import { fmtAgo, fmtDateTime, fmtInt, num } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'Data quality' };
9 +
10 +const CHECKS: { key: keyof Awaited<ReturnType<typeof adminApi.dataQuality>>['checks']; label: string; hint: string }[] = [
11 + { key: 'missing_norad', label: 'Missing NORAD', hint: 'satellites without a catalog number' },
12 + { key: 'missing_cospar', label: 'Missing COSPAR', hint: 'no international designator (analyst / unassigned objects)' },
13 + { key: 'active_without_operator', label: 'Active without operator', hint: 'active payloads/stations with no resolved operator' },
14 + { key: 'active_without_country', label: 'Active without country', hint: 'active objects whose owner code has no country' },
15 + { key: 'stale_active', label: 'Stale active', hint: 'active with GP but latest epoch > 30 d' },
16 + { key: 'broken_launch_links', label: 'Broken launch links', hint: 'COSPAR present but no launch row' },
17 + { key: 'active_without_elements', label: 'Active without elements', hint: 'active but never seen in the GP feed' },
18 +];
19 +
20 +export default async function AdminDataQualityPage({ searchParams }: { searchParams: Promise<{ page?: string; flag?: string }> }) {
21 + const sp = await searchParams;
22 + const page = Math.max(1, Number(sp.page) || 1);
23 + const flag = sp.flag || null;
24 + const res = await safeAdmin(adminApi.dataQuality(page, flag, 50));
25 + if (res.error !== null) {
26 + return (
27 + <>
28 + <AdminHeader title="Data quality" />
29 + <AdminError message={res.error} />
30 + </>
31 + );
32 + }
33 + const d = res.data;
34 + const now = Date.now();
35 + const href = (p: number, f: string | null = flag) => `/admin/data-quality?${new URLSearchParams({ ...(f ? { flag: f } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;
36 + return (
37 + <>
38 + <AdminHeader title="Data quality" lede="Live checks computed on the canonical satellites table, plus the open flags raised by the connectors during normalization. Flags are never auto-resolved by deleting data." />
39 +
40 + <SectionTitle>Checks (live counts)</SectionTitle>
41 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
42 + {CHECKS.map((c) => {
43 + const v = num(d.checks[c.key]) ?? 0;
44 + return <Tile key={c.key} label={c.label} value={fmtInt(v)} hint={c.hint} tone={v > 0 && (c.key === 'stale_active' || c.key === 'broken_launch_links' || c.key === 'missing_norad') ? 'warn' : undefined} />;
45 + })}
46 + </div>
47 +
48 + <SectionTitle>Open flags by type</SectionTitle>
49 + {d.summary.length === 0 ? (
50 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No open quality flags</p>
51 + ) : (
52 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
53 + <Link href={href(1, null)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${!flag ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
54 + All ({fmtInt(d.summary.reduce((a, s) => a + (num(s.open) ?? 0), 0))})
55 + </Link>
56 + {d.summary.map((s) => (
57 + <Link key={s.flag} href={href(1, s.flag)} className={`mono inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${flag === s.flag ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
58 + {s.flag} · {fmtInt(s.open)}
59 + </Link>
60 + ))}
61 + </div>
62 + )}
63 +
64 + <SectionTitle>Flagged satellites</SectionTitle>
65 + {d.data.length === 0 ? (
66 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No flagged rows{flag ? ` for ${flag}` : ''}</p>
67 + ) : (
68 + <>
69 + <div className="overflow-x-auto">
70 + <table className="data-table stack md:min-w-[800px]">
71 + <thead>
72 + <tr>
73 + <th>Satellite</th>
74 + <th>Flag</th>
75 + <th>Detail</th>
76 + <th>Raised</th>
77 + </tr>
78 + </thead>
79 + <tbody>
80 + {d.data.map((f) => (
81 + <tr key={f.id}>
82 + <td className="primary">
83 + {f.slug ? (
84 + <Link href={`/satellite/${f.slug}`} className="text-sm text-ink hover:text-accent">
85 + {f.name ?? f.slug}
86 + </Link>
87 + ) : (
88 + <span className="mono text-xs text-ink-2">
89 + {f.entity_type} {f.entity_id}
90 + </span>
91 + )}
92 + {f.norad_id !== null && <span className="mono ml-2 text-xs text-ink-3">NORAD {f.norad_id}</span>}
93 + </td>
94 + <td data-label="Flag" className="mono text-xs text-warn">
95 + {f.flag}
96 + </td>
97 + <td data-label="Detail" className="text-sm text-ink-2">
98 + {f.detail ?? '—'}
99 + </td>
100 + <td data-label="Raised" title={fmtDateTime(f.created_at)} className="text-sm text-ink-2">
101 + {fmtAgo(f.created_at, now)}
102 + </td>
103 + </tr>
104 + ))}
105 + </tbody>
106 + </table>
107 + </div>
108 + <Pagination className="mt-4" page={d.pagination.page} pages={d.pagination.pages} total={d.pagination.total} pageSize={d.pagination.page_size} makeHref={(p) => href(p)} />
109 + </>
110 + )}
111 + </>
112 + );
113 +}
added apps/web/src/app/admin/(protected)/entity-resolution/page.tsx +109 −0
@@ -0,0 +1,109 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ReviewDecisionButtons } from '@/components/admin/actions';
4 +import type { ReviewItem } from '@/components/admin/types';
5 +import { AdminError, AdminHeader, JsonInline } from '@/components/admin/ui';
6 +import { StatusBadge } from '@/components/ui/badges';
7 +import { Pagination } from '@/components/ui/pagination';
8 +import { adminApi, safeAdmin } from '@/lib/admin-api';
9 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
10 +
11 +export const metadata: Metadata = { title: 'Entity resolution' };
12 +
13 +const STATUSES = ['open', 'merged', 'kept_separate', 'dismissed'] as const;
14 +
15 +function Entity({ label, slug, name, norad, cospar, status }: { label: string; slug: string | null; name: string | null; norad: number | null; cospar: string | null; status: string | null }) {
16 + return (
17 + <div className="min-w-0 rounded-md border border-rule px-3 py-2.5">
18 + <p className="eyebrow">{label}</p>
19 + {slug ? (
20 + <Link href={`/satellite/${slug}`} className="mt-1 block truncate text-sm font-medium text-ink hover:text-accent">
21 + {name ?? slug}
22 + </Link>
23 + ) : (
24 + <p className="mt-1 text-sm text-ink-3">— (entity no longer exists)</p>
25 + )}
26 + <dl className="mono mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-ink-2">
27 + <dt className="text-ink-3">NORAD</dt>
28 + <dd>{norad ?? '—'}</dd>
29 + <dt className="text-ink-3">COSPAR</dt>
30 + <dd>{cospar ?? '—'}</dd>
31 + </dl>
32 + <div className="mt-2">
33 + <StatusBadge status={status} />
34 + </div>
35 + </div>
36 + );
37 +}
38 +
39 +function ReviewCard({ item, now }: { item: ReviewItem; now: number }) {
40 + return (
41 + <li className="border-t border-rule py-5">
42 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">
43 + <span className="mono text-ink-2">#{item.id}</span>
44 + <span className="rounded border border-rule px-1.5 py-0.5">{item.kind.replace(/_/g, ' ')}</span>
45 + <span>
46 + confidence <span className="tnum text-ink">{item.confidence === null ? '—' : item.confidence.toFixed(2)}</span>
47 + </span>
48 + <span title={fmtDateTime(item.created_at)}>raised {fmtAgo(item.created_at, now)}</span>
49 + {item.resolved_at && (
50 + <span>
51 + resolved {fmtAgo(item.resolved_at, now)} by {item.resolved_by ?? '?'}
52 + </span>
53 + )}
54 + </div>
55 + <div className="mt-3 grid gap-3 sm:grid-cols-2">
56 + <Entity label="A (kept on merge)" slug={item.a_slug} name={item.a_name} norad={item.a_norad} cospar={item.a_cospar} status={item.a_status} />
57 + <Entity label="B (merged into A)" slug={item.b_slug} name={item.b_name} norad={item.b_norad} cospar={item.b_cospar} status={item.b_status} />
58 + </div>
59 + {item.detail && (
60 + <div className="mt-2">
61 + <JsonInline value={item.detail} />
62 + </div>
63 + )}
64 + {item.status === 'open' && <ReviewDecisionButtons id={item.id} canMerge={!!item.entity_b_id && !!item.b_slug} className="mt-3" />}
65 + </li>
66 + );
67 +}
68 +
69 +export default async function AdminEntityResolutionPage({ searchParams }: { searchParams: Promise<{ page?: string; status?: string }> }) {
70 + const sp = await searchParams;
71 + const page = Math.max(1, Number(sp.page) || 1);
72 + const status = (STATUSES as readonly string[]).includes(sp.status ?? '') ? (sp.status as (typeof STATUSES)[number]) : 'open';
73 + const res = await safeAdmin(adminApi.review(page, status, 25));
74 + const now = Date.now();
75 + const href = (p: number, s: string = status) => `/admin/entity-resolution?${new URLSearchParams({ ...(s !== 'open' ? { status: s } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;
76 + return (
77 + <>
78 + <AdminHeader title="Entity resolution" lede="Ambiguous matches from the resolver (NORAD → COSPAR → exact normalized name) are never merged automatically. Decide here; merges move aliases, identifiers and element history from B to A and are recorded in entity_merges with a snapshot of B." />
79 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:px-0">
80 + {STATUSES.map((s) => (
81 + <Link key={s} href={href(1, s)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${status === s ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
82 + {s.replace('_', ' ')}
83 + </Link>
84 + ))}
85 + </div>
86 + {res.error !== null ? (
87 + <div className="mt-6">
88 + <AdminError message={res.error} />
89 + </div>
90 + ) : (
91 + <>
92 + <p className="mt-5 text-xs text-ink-3">
93 + {fmtInt(res.data.pagination.total)} {status.replace('_', ' ')} item{res.data.pagination.total === 1 ? '' : 's'}
94 + </p>
95 + {res.data.data.length === 0 ? (
96 + <p className="mt-3 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Queue is empty</p>
97 + ) : (
98 + <ul className="mt-2 border-b border-rule">
99 + {res.data.data.map((item) => (
100 + <ReviewCard key={item.id} item={item} now={now} />
101 + ))}
102 + </ul>
103 + )}
104 + <Pagination className="mt-4" page={res.data.pagination.page} pages={res.data.pagination.pages} total={res.data.pagination.total} pageSize={res.data.pagination.page_size} makeHref={(p) => href(p)} />
105 + </>
106 + )}
107 + </>
108 + );
109 +}
added apps/web/src/app/admin/(protected)/layout.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import type { ReactNode } from 'react';
2 +import { AdminNav } from '@/components/admin/nav';
3 +import { Container } from '@/components/ui/section';
4 +import { requireAdmin } from '@/lib/admin-auth';
5 +
6 +/** Every page in this group requires the admin session cookie; anonymous visitors are redirected to /admin/login. */
7 +export default async function AdminProtectedLayout({ children }: { children: ReactNode }) {
8 + await requireAdmin();
9 + return (
10 + <Container wide className="py-6 md:py-8">
11 + <div className="grid gap-6 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-10">
12 + <aside className="min-w-0 lg:sticky lg:top-[calc(var(--header-h)+1.5rem)] lg:self-start">
13 + <p className="eyebrow mb-2 hidden lg:block">Admin console</p>
14 + <AdminNav />
15 + </aside>
16 + <div className="min-w-0">{children}</div>
17 + </div>
18 + </Container>
19 + );
20 +}
added apps/web/src/app/admin/(protected)/page.tsx +130 −0
@@ -0,0 +1,130 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EnabledToggle, RunNowButton } from '@/components/admin/actions';
4 +import { AdminError, AdminHeader, JsonInline, RunsTable, SectionTitle, Tile, fmtBytes } from '@/components/admin/ui';
5 +import { RunStatusPill, fmtDuration, fmtInterval, fmtUntil } from '@/components/meta/connectors-table';
6 +import { adminApi, safeAdmin } from '@/lib/admin-api';
7 +import { fmtAgo, fmtDateTime, fmtInt, num } from '@/lib/format';
8 +
9 +export const metadata: Metadata = { title: 'Overview' };
10 +
11 +export default async function AdminOverviewPage() {
12 + const res = await safeAdmin(adminApi.overview());
13 + if (res.error !== null) {
14 + return (
15 + <>
16 + <AdminHeader title="Overview" />
17 + <AdminError message={res.error} />
18 + </>
19 + );
20 + }
21 + const d = res.data.data;
22 + const now = Date.now();
23 + const db = d.database;
24 + const openFlags = d.quality.reduce((acc, q) => acc + (num(q.open) ?? 0), 0);
25 + return (
26 + <>
27 + <AdminHeader title="Overview" lede={`Generated ${fmtDateTime(res.data.meta.generated_at)} · request ${res.data.meta.request_id}`} action={<Link href="/status" className="text-sm text-accent hover:underline">Public status →</Link>} />
28 +
29 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8">
30 + <Tile label="Satellites" value={fmtInt(db.satellites)} />
31 + <Tile label="Element sets" value={fmtInt(db.elements)} hint="orbital_state rows" />
32 + <Tile label="Events" value={fmtInt(db.events)} />
33 + <Tile label="Raw snapshots" value={fmtInt(db.raw)} hint={`${fmtBytes(db.raw_bytes)} uncompressed`} href="/admin/raw" />
34 + <Tile label="Database" value={fmtBytes(db.db_bytes)} hint={`${fmtInt(db.db_connections)} connections`} />
35 + <Tile label="Redis" value={d.redis ? 'ok' : 'down'} tone={d.redis ? 'ok' : 'danger'} hint={`queue depth ${fmtInt(d.queue_depth)}`} />
36 + <Tile label="Open quality flags" value={fmtInt(openFlags)} tone={openFlags > 0 ? 'warn' : undefined} href="/admin/data-quality" />
37 + <Tile label="Review queue" value={fmtInt(d.review_open)} tone={d.review_open > 0 ? 'warn' : undefined} href="/admin/entity-resolution" />
38 + </div>
39 +
40 + <SectionTitle>Connectors</SectionTitle>
41 + <div className="overflow-x-auto">
42 + <table className="data-table stack md:min-w-[1100px]">
43 + <thead>
44 + <tr>
45 + <th>Connector</th>
46 + <th>Status</th>
47 + <th>Last success</th>
48 + <th>Next run</th>
49 + <th className="num">Duration</th>
50 + <th className="num">Failures</th>
51 + <th className="num">Errors 7 d</th>
52 + <th>Circuit</th>
53 + <th>Actions</th>
54 + </tr>
55 + </thead>
56 + <tbody>
57 + {d.connectors.map((c) => {
58 + const circuitOpen = !!c.circuit_open_until && new Date(c.circuit_open_until).getTime() > now;
59 + return (
60 + <tr key={c.name}>
61 + <td className="primary">
62 + <Link href={`/admin/connectors/${encodeURIComponent(c.name)}`} className="mono text-sm text-ink hover:text-accent">
63 + {c.name}
64 + </Link>
65 + <div className="text-xs text-ink-3">
66 + {c.source_name} · every {fmtInterval(c.interval_seconds)} · priority {c.priority}
67 + {!c.enabled && <span className="ml-2 text-warn">disabled</span>}
68 + </div>
69 + {c.description && <div className="mt-0.5 max-w-md text-xs text-ink-3">{c.description}</div>}
70 + </td>
71 + <td data-label="Status">
72 + <RunStatusPill status={c.last_status} />
73 + </td>
74 + <td data-label="Last success" title={fmtDateTime(c.last_success_at)} className="text-sm">
75 + {fmtAgo(c.last_success_at, now)}
76 + </td>
77 + <td data-label="Next run" title={fmtDateTime(c.next_run_at)} className="text-sm text-ink-2">
78 + {c.enabled ? fmtUntil(c.next_run_at, now) : '—'}
79 + </td>
80 + <td data-label="Duration" className="num tnum text-sm text-ink-2">
81 + {fmtDuration(c.last_duration_ms)}
82 + </td>
83 + <td data-label="Failures" className={`num tnum text-sm ${c.consecutive_failures > 0 ? 'text-warn' : 'text-ink-2'}`}>
84 + {fmtInt(c.consecutive_failures)}
85 + </td>
86 + <td data-label="Errors 7 d" className={`num tnum text-sm ${(num(c.errors_7d) ?? 0) > 0 ? 'text-warn' : 'text-ink-2'}`}>
87 + {fmtInt(c.errors_7d)}
88 + </td>
89 + <td data-label="Circuit">{circuitOpen ? <span className="text-xs text-danger">open · resets {fmtUntil(c.circuit_open_until, now)}</span> : <span className="text-xs text-ink-3">closed</span>}</td>
90 + <td data-label="Actions" className="primary md:[grid-column:auto]">
91 + <div className="flex flex-wrap items-center gap-2">
92 + <RunNowButton name={c.name} />
93 + <EnabledToggle name={c.name} enabled={c.enabled} />
94 + </div>
95 + </td>
96 + </tr>
97 + );
98 + })}
99 + </tbody>
100 + </table>
101 + </div>
102 + <p className="mt-2 text-xs text-ink-3">
103 + &ldquo;Run now&rdquo; pushes a job to the Redis queue consumed by the scheduler (<code className="mono">si schedule</code>). If Redis is down the job is not visible to the scheduler and the button says so. Enabling a connector also resets its circuit breaker.
104 + </p>
105 +
106 + {d.failed_jobs.length > 0 && (
107 + <>
108 + <SectionTitle>Failed jobs</SectionTitle>
109 + <RunsTable runs={d.failed_jobs} now={now} />
110 + </>
111 + )}
112 +
113 + <SectionTitle>Recent runs</SectionTitle>
114 + <RunsTable runs={d.recent_runs} now={now} />
115 + {d.recent_runs.some((r) => r.meta) && (
116 + <details className="mt-3 text-xs text-ink-3">
117 + <summary className="cursor-pointer py-1 hover:text-ink">Run metadata (latest 5)</summary>
118 + <ul className="mt-2 space-y-1">
119 + {d.recent_runs.slice(0, 5).map((r) => (
120 + <li key={r.id} className="grid gap-1 sm:grid-cols-[220px_minmax(0,1fr)]">
121 + <span className="mono">{r.connector_name}</span>
122 + <JsonInline value={r.meta} />
123 + </li>
124 + ))}
125 + </ul>
126 + </details>
127 + )}
128 + </>
129 + );
130 +}
added apps/web/src/app/admin/(protected)/raw/[id]/page.tsx +67 −0
@@ -0,0 +1,67 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { AdminError, AdminHeader, fmtBytes } from '@/components/admin/ui';
5 +import { KeyValues } from '@/components/meta/prose';
6 +import { adminApi, AdminApiError } from '@/lib/admin-api';
7 +import { fmtDateTime, fmtInt } from '@/lib/format';
8 +
9 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
10 + const { id } = await params;
11 + return { title: `Raw ${id}` };
12 +}
13 +
14 +export default async function AdminRawRecordPage({ params }: { params: Promise<{ id: string }> }) {
15 + const { id } = await params;
16 + let record;
17 + try {
18 + record = (await adminApi.rawRecord(id)).data;
19 + } catch (e) {
20 + if (e instanceof AdminApiError && e.notFound) notFound();
21 + return (
22 + <>
23 + <AdminHeader title="Raw record" />
24 + <AdminError message={(e as Error).message} />
25 + </>
26 + );
27 + }
28 + return (
29 + <>
30 + <AdminHeader
31 + title={<span className="mono break-all">{record.source_native_id ?? record.id}</span>}
32 + lede={`${record.connector_name} · fetched ${fmtDateTime(record.fetched_at)}`}
33 + action={
34 + <Link href={`/admin/raw?connector=${encodeURIComponent(record.connector_name)}`} className="text-sm text-accent hover:underline">
35 + ← Raw records
36 + </Link>
37 + }
38 + />
39 + <KeyValues
40 + rows={[
41 + { k: 'Record id', v: <span className="mono text-xs">{record.id}</span> },
42 + { k: 'Run', v: record.run_id ? <Link href={`/admin/connectors/${encodeURIComponent(record.connector_name)}`} className="mono text-xs link">{record.run_id}</Link> : '—' },
43 + { k: 'Source', v: <span className="mono text-xs">{record.source_id}</span> },
44 + { k: 'Source URL', v: record.source_url ? <a href={record.source_url} rel="noopener noreferrer" target="_blank" className="link mono break-all text-xs">{record.source_url}</a> : '—' },
45 + { k: 'Storage path', v: <span className="mono break-all text-xs">{record.storage_path ?? '— (payload not kept: unchanged upstream)'}</span> },
46 + { k: 'Content type', v: <span className="mono text-xs">{record.content_type ?? '—'}</span> },
47 + { k: 'Size / records', v: `${fmtBytes(record.byte_size)} · ${fmtInt(record.record_count)} records` },
48 + { k: 'Payload hash', v: <span className="mono break-all text-xs">{record.payload_hash ?? '—'}</span> },
49 + { k: 'Processing', v: <span className={record.processing_status === 'processed' ? 'text-active' : record.processing_status === 'failed' ? 'text-danger' : ''}>{record.processing_status ?? '—'}{record.processed_at ? ` · ${fmtDateTime(record.processed_at)}` : ''}</span> },
50 + ...(record.error ? [{ k: 'Error', v: <span className="mono text-xs text-danger">{record.error}</span> }] : []),
51 + ]}
52 + />
53 +
54 + <div className="mt-6 flex items-end justify-between gap-3">
55 + <h2 className="text-base font-semibold tracking-tight md:text-lg">Payload preview</h2>
56 + {record.truncated && <span className="rounded border border-warn/40 bg-warn-soft px-2 py-0.5 text-xs text-warn">truncated at 200 KB</span>}
57 + </div>
58 + {record.preview === null ? (
59 + <p className="mt-3 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Payload file unavailable on this host (no storage path or file missing under SI_DATA_DIR/raw)</p>
60 + ) : (
61 + <pre className="scrollbar-thin mt-3 max-h-[70vh] overflow-auto rounded-md border border-rule bg-plane-2 p-3 text-[12px] leading-relaxed text-ink">
62 + <code className="mono">{record.preview}</code>
63 + </pre>
64 + )}
65 + </>
66 + );
67 +}
added apps/web/src/app/admin/(protected)/raw/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminError, AdminHeader, fmtBytes } from '@/components/admin/ui';
4 +import { Pagination } from '@/components/ui/pagination';
5 +import { adminApi, safeAdmin } from '@/lib/admin-api';
6 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'Raw records' };
9 +
10 +const KNOWN_CONNECTORS = ['celestrak_gp', 'celestrak_groups', 'celestrak_satcat', 'derived_analytics'];
11 +
12 +export default async function AdminRawPage({ searchParams }: { searchParams: Promise<{ page?: string; connector?: string }> }) {
13 + const sp = await searchParams;
14 + const page = Math.max(1, Number(sp.page) || 1);
15 + const connector = sp.connector || null;
16 + const [res, overview] = await Promise.all([safeAdmin(adminApi.raw(page, connector, 50)), safeAdmin(adminApi.overview())]);
17 + const connectors = overview.data ? overview.data.data.connectors.map((c) => c.name) : KNOWN_CONNECTORS;
18 + const now = Date.now();
19 + const href = (p: number, c: string | null = connector) => `/admin/raw?${new URLSearchParams({ ...(c ? { connector: c } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;
20 + return (
21 + <>
22 + <AdminHeader title="Raw records" lede="Every upstream payload is stored as an immutable gzip snapshot before normalization (SI_DATA_DIR/raw). Nothing here is ever rewritten." />
23 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
24 + <Link href={href(1, null)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${!connector ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
25 + All connectors
26 + </Link>
27 + {connectors.map((c) => (
28 + <Link key={c} href={href(1, c)} className={`mono inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${connector === c ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
29 + {c}
30 + </Link>
31 + ))}
32 + </div>
33 +
34 + {res.error !== null ? (
35 + <div className="mt-6">
36 + <AdminError message={res.error} />
37 + </div>
38 + ) : res.data.data.length === 0 ? (
39 + <p className="mt-6 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">No raw records{connector ? ` for ${connector}` : ''}</p>
40 + ) : (
41 + <>
42 + <div className="mt-5 overflow-x-auto">
43 + <table className="data-table stack md:min-w-[960px]">
44 + <thead>
45 + <tr>
46 + <th>Record</th>
47 + <th>Connector</th>
48 + <th>Fetched</th>
49 + <th>Processing</th>
50 + <th className="num">Records</th>
51 + <th className="num">Size</th>
52 + <th>Type</th>
53 + </tr>
54 + </thead>
55 + <tbody>
56 + {res.data.data.map((r) => (
57 + <tr key={r.id}>
58 + <td className="primary">
59 + <Link href={`/admin/raw/${encodeURIComponent(r.id)}`} className="mono text-sm text-ink hover:text-accent">
60 + {r.source_native_id ?? r.id}
61 + </Link>
62 + <div className="mono truncate text-[11px] text-ink-3" title={r.id}>
63 + {r.id}
64 + </div>
65 + </td>
66 + <td data-label="Connector" className="mono text-xs text-ink-2">
67 + {r.connector_name}
68 + </td>
69 + <td data-label="Fetched" title={fmtDateTime(r.fetched_at)} className="text-sm">
70 + {fmtAgo(r.fetched_at, now)}
71 + </td>
72 + <td data-label="Processing">
73 + <span className={`mono text-xs ${r.processing_status === 'processed' ? 'text-active' : r.processing_status === 'failed' ? 'text-danger' : 'text-ink-2'}`}>{r.processing_status ?? '—'}</span>
74 + {r.error && (
75 + <span className="mono block max-w-[240px] truncate text-[11px] text-danger" title={r.error}>
76 + {r.error}
77 + </span>
78 + )}
79 + </td>
80 + <td data-label="Records" className="num tnum text-sm">
81 + {fmtInt(r.record_count)}
82 + </td>
83 + <td data-label="Size" className="num tnum text-sm text-ink-2">
84 + {fmtBytes(r.byte_size)}
85 + </td>
86 + <td data-label="Type" className="mono text-xs text-ink-3">
87 + {r.content_type ?? '—'}
88 + </td>
89 + </tr>
90 + ))}
91 + </tbody>
92 + </table>
93 + </div>
94 + <Pagination className="mt-4" page={res.data.pagination.page} pages={res.data.pagination.pages} total={res.data.pagination.total} pageSize={res.data.pagination.page_size} makeHref={(p) => href(p)} />
95 + </>
96 + )}
97 + </>
98 + );
99 +}
added apps/web/src/app/admin/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import type { Metadata } from 'next';
2 +import type { ReactNode } from 'react';
3 +
4 +/** Everything under /admin is private: never indexed, never cached (see `headers()` in next.config.ts). */
5 +export const metadata: Metadata = {
6 + title: { default: 'Admin', template: '%s · Admin | SatelliteIndex' },
7 + robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
8 +};
9 +
10 +export const dynamic = 'force-dynamic';
11 +
12 +export default function AdminRootLayout({ children }: { children: ReactNode }) {
13 + return <>{children}</>;
14 +}
added apps/web/src/app/admin/login/page.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import type { Metadata } from 'next';
2 +import { redirect } from 'next/navigation';
3 +import { LogoMark } from '@/components/brand/logo';
4 +import { Container } from '@/components/ui/section';
5 +import { isAdmin, safeNextPath } from '@/lib/admin-auth';
6 +
7 +export const metadata: Metadata = { title: 'Sign in', robots: { index: false, follow: false } };
8 +
9 +export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
10 + const sp = await searchParams;
11 + const next = safeNextPath(sp.next);
12 + if (await isAdmin()) redirect(next);
13 + const error = sp.error === 'invalid' ? 'That token is not valid.' : sp.error === 'unconfigured' ? 'SI_ADMIN_TOKEN is not configured on the server — the admin is disabled.' : null;
14 + return (
15 + <Container className="flex min-h-[60vh] items-center justify-center py-12">
16 + <form method="post" action="/api/admin/login" className="w-full max-w-sm rounded-lg border border-rule bg-plane/60 p-6">
17 + <div className="flex items-center gap-2">
18 + <LogoMark size={22} className="text-ink" />
19 + <p className="eyebrow">SatelliteIndex admin</p>
20 + </div>
21 + <h1 className="mt-3 text-xl font-semibold tracking-tight">Sign in</h1>
22 + <p className="mt-1 text-sm text-ink-2">Paste the operator token. It is verified on the server and never stored in the browser — only a hashed session cookie is set.</p>
23 + <label htmlFor="token" className="eyebrow mt-5 block">
24 + Admin token
25 + </label>
26 + <input
27 + id="token"
28 + name="token"
29 + type="password"
30 + autoComplete="current-password"
31 + required
32 + autoFocus
33 + className="mono mt-1.5 block h-11 w-full rounded-md border border-rule bg-plane-2 px-3 text-sm text-ink outline-none focus:border-accent"
34 + />
35 + <input type="hidden" name="next" value={next} />
36 + {error && (
37 + <p role="alert" className="mt-3 text-sm text-danger">
38 + {error}
39 + </p>
40 + )}
41 + <button type="submit" className="mt-4 inline-flex h-11 w-full items-center justify-center rounded-md bg-accent text-sm font-semibold text-accent-ink hover:brightness-110">
42 + Continue
43 + </button>
44 + </form>
45 + </Container>
46 + );
47 +}
added apps/web/src/app/api/admin/connectors/[name]/enabled/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextResponse } from 'next/server';
2 +import { adminApi, AdminApiError } from '@/lib/admin-api';
3 +import { isAdmin } from '@/lib/admin-auth';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +/** Enable / disable a connector: body `{ "enabled": boolean }` → FastAPI `/admin/connectors/{name}/enabled`. */
8 +export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }): Promise<Response> {
9 + if (!(await isAdmin())) return NextResponse.json({ error: { title: 'Unauthorized', status: 401 } }, { status: 401 });
10 + const { name } = await ctx.params;
11 + const body = (await req.json().catch(() => null)) as { enabled?: unknown } | null;
12 + if (!body || typeof body.enabled !== 'boolean') return NextResponse.json({ error: { title: 'Invalid body', detail: '`enabled` must be a boolean', status: 422 } }, { status: 422 });
13 + try {
14 + const out = await adminApi.setEnabled(name, body.enabled);
15 + return NextResponse.json(out.data);
16 + } catch (e) {
17 + const status = e instanceof AdminApiError && e.status > 0 ? e.status : 502;
18 + return NextResponse.json({ error: { title: 'Toggle failed', detail: (e as Error).message, status } }, { status });
19 + }
20 +}
added apps/web/src/app/api/admin/connectors/[name]/run/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { NextResponse } from 'next/server';
2 +import { adminApi, AdminApiError } from '@/lib/admin-api';
3 +import { isAdmin } from '@/lib/admin-auth';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +/** Queue a manual connector run (proxied to FastAPI with the server-side token). Requires the admin session cookie. */
8 +export async function POST(_req: Request, ctx: { params: Promise<{ name: string }> }): Promise<Response> {
9 + if (!(await isAdmin())) return NextResponse.json({ error: { title: 'Unauthorized', status: 401 } }, { status: 401 });
10 + const { name } = await ctx.params;
11 + try {
12 + const out = await adminApi.triggerRun(name);
13 + return NextResponse.json(out.data);
14 + } catch (e) {
15 + const status = e instanceof AdminApiError && e.status > 0 ? e.status : 502;
16 + return NextResponse.json({ error: { title: 'Run request failed', detail: (e as Error).message, status } }, { status });
17 + }
18 +}
added apps/web/src/app/api/admin/login/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextResponse } from 'next/server';
2 +import { ADMIN_COOKIE, cookieOptions, hashToken, safeNextPath, verifyToken } from '@/lib/admin-auth';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +/** Login form target: compares the posted token with SI_ADMIN_TOKEN server-side and sets the httpOnly session cookie. */
7 +export async function POST(req: Request): Promise<Response> {
8 + const form = await req.formData().catch(() => null);
9 + const token = typeof form?.get('token') === 'string' ? String(form.get('token')).trim() : '';
10 + const next = safeNextPath(typeof form?.get('next') === 'string' ? String(form.get('next')) : null);
11 + if (!verifyToken(token)) {
12 + const url = new URL('/admin/login', req.url);
13 + url.searchParams.set('error', process.env.SI_ADMIN_TOKEN ? 'invalid' : 'unconfigured');
14 + if (next !== '/admin') url.searchParams.set('next', next);
15 + return NextResponse.redirect(url, 303);
16 + }
17 + const res = NextResponse.redirect(new URL(next, req.url), 303);
18 + res.cookies.set(ADMIN_COOKIE, hashToken(token), cookieOptions());
19 + return res;
20 +}
added apps/web/src/app/api/admin/logout/route.ts +10 −0
@@ -0,0 +1,10 @@
1 +import { NextResponse } from 'next/server';
2 +import { ADMIN_COOKIE } from '@/lib/admin-auth';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +export async function POST(req: Request): Promise<Response> {
7 + const res = NextResponse.redirect(new URL('/admin/login', req.url), 303);
8 + res.cookies.set(ADMIN_COOKIE, '', { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 0 });
9 + return res;
10 +}
added apps/web/src/app/api/admin/review/[id]/route.ts +27 −0
@@ -0,0 +1,27 @@
1 +import { NextResponse } from 'next/server';
2 +import type { ReviewDecision } from '@/components/admin/types';
3 +import { adminApi, AdminApiError } from '@/lib/admin-api';
4 +import { isAdmin } from '@/lib/admin-auth';
5 +
6 +export const dynamic = 'force-dynamic';
7 +
8 +const DECISIONS: ReviewDecision[] = ['merged', 'kept_separate', 'dismissed'];
9 +
10 +/** Entity-resolution decision: body `{ "decision": "merged" | "kept_separate" | "dismissed" }`. Merges are audited server-side (entity_merges). */
11 +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
12 + if (!(await isAdmin())) return NextResponse.json({ error: { title: 'Unauthorized', status: 401 } }, { status: 401 });
13 + const { id } = await ctx.params;
14 + const itemId = Number(id);
15 + const body = (await req.json().catch(() => null)) as { decision?: unknown } | null;
16 + const decision = body?.decision;
17 + if (!Number.isInteger(itemId) || typeof decision !== 'string' || !DECISIONS.includes(decision as ReviewDecision)) {
18 + return NextResponse.json({ error: { title: 'Invalid request', detail: 'decision must be merged | kept_separate | dismissed', status: 422 } }, { status: 422 });
19 + }
20 + try {
21 + const out = await adminApi.decide(itemId, decision as ReviewDecision, 'admin-web');
22 + return NextResponse.json(out.data);
23 + } catch (e) {
24 + const status = e instanceof AdminApiError && e.status > 0 ? e.status : 502;
25 + return NextResponse.json({ error: { title: 'Decision failed', detail: (e as Error).message, status } }, { status });
26 + }
27 +}
added apps/web/src/app/apple-icon.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { ImageResponse } from 'next/og';
2 +
3 +/** 180×180 PNG generated from the SatelliteIndex mark (planet + inclined ring + cyan node) on the space background. */
4 +export const size = { width: 180, height: 180 };
5 +export const contentType = 'image/png';
6 +
7 +export default function AppleIcon() {
8 + return new ImageResponse(
9 + (
10 + <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#060912', borderRadius: 40 }}>
11 + <svg width="132" height="132" viewBox="0 0 32 32" fill="none">
12 + <circle cx="16" cy="16" r="7.5" fill="#eaf0ff" opacity="0.92" />
13 + <ellipse cx="16" cy="16" rx="14" ry="5.2" stroke="#eaf0ff" strokeWidth="1.6" transform="rotate(-24 16 16)" opacity="0.85" />
14 + <circle cx="27.4" cy="9.6" r="2.3" fill="#38d3ff" />
15 + </svg>
16 + </div>
17 + ),
18 + { ...size },
19 + );
20 +}
added apps/web/src/app/constellation/[slug]/opengraph-image.tsx +55 −0
@@ -0,0 +1,55 @@
1 +import { ImageResponse } from 'next/og';
2 +import { api, safe } from '@/lib/api';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +export const alt = 'Constellation summary — SatelliteIndex';
6 +export const size = { width: 1200, height: 630 };
7 +export const contentType = 'image/png';
8 +
9 +const ORBIT_HEX: Record<string, string> = { LEO: '#38d3ff', MEO: '#8f7dff', GEO: '#f5b544', HEO: '#ff7ab6' };
10 +
11 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const res = await safe(api.constellation(slug));
14 + const d = res?.data ?? null;
15 + const orbit = d?.orbit_class ?? 'MIXED';
16 + const color = ORBIT_HEX[orbit] ?? '#7c869e';
17 +
18 + return new ImageResponse(
19 + (
20 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: 'linear-gradient(135deg, #060912 0%, #0b1020 60%, #111830 100%)', color: '#eaf0ff', fontFamily: 'sans-serif' }}>
21 + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
22 + <div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: 26, letterSpacing: 4, textTransform: 'uppercase', color: '#a3aec8' }}>
23 + <div style={{ width: 14, height: 14, borderRadius: 999, background: '#38d3ff' }} />
24 + <div>SatelliteIndex · Constellation</div>
25 + </div>
26 + <div style={{ display: 'flex', padding: '8px 18px', borderRadius: 8, border: `2px solid ${color}`, color, fontSize: 28, fontWeight: 700 }}>{orbit}</div>
27 + </div>
28 +
29 + {d ? (
30 + <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
31 + <div style={{ fontSize: d.name.length > 28 ? 60 : 84, fontWeight: 700, letterSpacing: -2, lineHeight: 1 }}>{d.name}</div>
32 + <div style={{ fontSize: 34, color: '#a3aec8' }}>{`${d.operator_name ?? 'Operator unavailable'}${d.country_name ? ` · ${d.country_name}` : ''}`}</div>
33 + </div>
34 + ) : (
35 + <div style={{ fontSize: 72, fontWeight: 700 }}>Constellation</div>
36 + )}
37 +
38 + <div style={{ display: 'flex', gap: 56, borderTop: '1px solid rgba(160,180,230,0.28)', paddingTop: 28 }}>
39 + {[
40 + { label: 'Active satellites', value: d ? fmtInt(d.active) : '—', c: '#38d17f' },
41 + { label: 'Total launched', value: d ? fmtInt(d.total) : '—', c: '#eaf0ff' },
42 + { label: 'Launched 365 d', value: d ? fmtInt(d.launched_last_365d) : '—', c: '#eaf0ff' },
43 + { label: 'Launches', value: d ? fmtInt(d.launches) : '—', c: '#eaf0ff' },
44 + ].map((k) => (
45 + <div key={k.label} style={{ display: 'flex', flexDirection: 'column' }}>
46 + <div style={{ fontSize: 20, letterSpacing: 3, textTransform: 'uppercase', color: '#6b7694' }}>{k.label}</div>
47 + <div style={{ fontSize: 56, fontWeight: 700, color: k.c, marginTop: 6 }}>{k.value}</div>
48 + </div>
49 + ))}
50 + </div>
51 + </div>
52 + ),
53 + size,
54 + );
55 +}
modified apps/web/src/app/constellation/[slug]/page.tsx +2 −2
@@ -112,7 +112,7 @@ export default async function ConstellationPage({ params }: Props) {
112 112 <Block eyebrow="Orbits" title="Orbital shells" id="shells">
113 113 <OrbitalShells d={d} />
114 114 </Block>
115 − <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches" action={{ href: routes.launches(`constellation=${encodeURIComponent(d.slug)}`), label: 'All launches' }}>
115 + <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">
116 116 <LaunchesTable rows={d.launches_list} showActive />
117 117 {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}
118 118 </Block>
@@ -124,7 +124,7 @@ export default async function ConstellationPage({ params }: Props) {
124 124 <DecaysByMonth rows={d.decays_by_month} />
125 125 </Block>
126 126 )}
127 − <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`constellation=${encodeURIComponent(d.slug)}`), label: 'All events' }}>
127 + <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>
128 128 <EventsList events={d.events} />
129 129 </Block>
130 130 </div>
modified apps/web/src/app/constellations/page.tsx +12 −11
@@ -19,6 +19,7 @@ const SORTS: { value: string | undefined; label: string }[] = [
19 19 { value: 'activity', label: 'Activity' },
20 20 { value: 'name', label: 'Name' },
21 21 ];
22 +const SORT_NOUN: Record<string, string> = { '': 'active satellites', total: 'total satellites launched', growth: 'satellites launched in the last 365 days', activity: 'activity score', name: 'name' };
22 23 const SERVICES = ['communications', 'earth-observation', 'navigation', 'iot', 'weather', 'military', 'technology', 'station', 'science'];
23 24 const ORBITS = ['LEO', 'MEO', 'GEO', 'HEO', 'MIXED'];
24 25
@@ -53,7 +54,7 @@ export default async function ConstellationsPage({ searchParams }: { searchParam
53 54 lede={
54 55 total !== null ? (
55 56 <>
56 − {fmtInt(total)} constellations tracked{params.service || params.orbit ? ' in this selection' : ''}, ranked by {SORTS.find((s) => (s.value ?? '') === (params.sort ?? ''))?.label.toLowerCase() ?? 'active satellites'}.
57 + {fmtInt(total)} constellations tracked{params.service || params.orbit ? ' in this selection' : ''}, ranked by {SORT_NOUN[params.sort ?? ''] ?? 'active satellites'}.
57 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>.
58 59 </>
59 60 ) : (
@@ -97,21 +98,21 @@ function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset
97 98 <table className="data-table stack">
98 99 <thead>
99 100 <tr>
100 − <th className="num">#</th>
101 + <th className="num max-md:hidden!">#</th>
101 102 <th>Constellation</th>
102 103 <th>Operator</th>
103 104 <th>Service</th>
104 105 <th>Orbit</th>
105 106 <th className="num">Active</th>
106 − <th className="num">On orbit</th>
107 + <th className="num max-md:hidden!">On orbit</th>
107 108 <th className="num">Total</th>
108 109 <th className="num">365 d</th>
109 − <th className="num">30 d</th>
110 + <th className="num max-md:hidden!">30 d</th>
110 111 <th className="num">
111 112 <span className="inline-flex items-center gap-1">Activity <Derived /></span>
112 113 </th>
113 − <th className="num">Median perigee</th>
114 − <th>First launch</th>
114 + <th className="num max-md:hidden!">Median perigee</th>
115 + <th className="max-md:hidden!">First launch</th>
115 116 <th>Last launch</th>
116 117 </tr>
117 118 </thead>
@@ -119,7 +120,7 @@ function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset
119 120 {rows.length === 0 && <EmptyRow colSpan={14}>No constellation matches these filters.</EmptyRow>}
120 121 {rows.map((c, i) => (
121 122 <tr key={c.id}>
122 − <td data-label="Rank" className="num mono text-xs text-ink-3">{offset + i + 1}</td>
123 + <td data-label="Rank" className="num mono text-xs text-ink-3 max-md:hidden!">{offset + i + 1}</td>
123 124 <td className="primary" data-label="Constellation">
124 125 <Link href={routes.constellation(c.slug)} className="link font-medium">{c.name}</Link>
125 126 {c.country_code && <span className="mono ml-2 text-xs text-ink-3">{c.country_code}</span>}
@@ -128,13 +129,13 @@ function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset
128 129 <td data-label="Service" className="text-ink-2">{titleCase(c.service_type)}</td>
129 130 <td data-label="Orbit"><OrbitBadge orbitClass={c.orbit_class} /></td>
130 131 <td data-label="Active" className="num tnum font-medium text-active">{fmtInt(c.active)}</td>
131 − <td data-label="On orbit" className="num tnum">{fmtInt(c.on_orbit)}</td>
132 + <td data-label="On orbit" className="num tnum max-md:hidden!">{fmtInt(c.on_orbit)}</td>
132 133 <td data-label="Total" className="num tnum">{fmtInt(c.total)}</td>
133 134 <td data-label="Launched 365 d" className="num tnum">{fmtInt(c.launched_last_365d)}</td>
134 − <td data-label="Launched 30 d" className="num tnum">{fmtInt(c.launched_last_30d)}</td>
135 + <td data-label="Launched 30 d" className="num tnum max-md:hidden!">{fmtInt(c.launched_last_30d)}</td>
135 136 <td data-label="Activity score" className="num tnum text-accent-2">{fmt1(c.activity_score)}</td>
136 − <td data-label="Median perigee" className="num tnum">{fmtKm(c.median_perigee_km)}</td>
137 − <td data-label="First launch" className="tnum text-ink-2">{fmtDate(c.first_launch)}</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>
138 139 <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(c.last_launch)}</td>
139 140 </tr>
140 141 ))}
added apps/web/src/app/countries/page.tsx +135 −0
@@ -0,0 +1,135 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { HBars } from '@/components/charts/charts';
4 +import { ChipRow, EmptyRow, ScrollTable, entityMetadata, first, type Params } from '@/components/entities/shared';
5 +import { Container, PageHeader } from '@/components/ui/section';
6 +import { Unavailable } from '@/components/ui/unavailable';
7 +import { api, safe } from '@/lib/api';
8 +import { fmtInt, num } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import type { CountryRow } from '@/lib/types';
11 +
12 +type SearchParams = Record<string, string | string[] | undefined>;
13 +
14 +const SORTS: { value: string | undefined; label: string; key: keyof CountryRow; noun: string }[] = [
15 + { value: undefined, label: 'Active payloads', key: 'active_payloads', noun: 'active payloads' },
16 + { value: 'objects', label: 'Objects on orbit', key: 'objects_on_orbit', noun: 'objects on orbit' },
17 + { value: 'debris', label: 'Debris', key: 'debris_on_orbit', noun: 'debris fragments on orbit' },
18 + { value: 'launches', label: 'Launches', key: 'launches', noun: 'launches' },
19 + { value: 'name', label: 'Name', key: 'name', noun: 'name' },
20 +];
21 +
22 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<Metadata> {
23 + const sort = first((await searchParams).sort);
24 + const s = SORTS.find((x) => (x.value ?? '') === (sort ?? '')) ?? SORTS[0]!;
25 + return entityMetadata({
26 + title: `Countries in orbit — ranked by ${s.noun}`,
27 + description: 'Every country with catalogued objects in Earth orbit, ranked by active payloads, objects on orbit, debris, rocket bodies, launches and operators — with regional breakdown.',
28 + path: routes.countries(),
29 + });
30 +}
31 +
32 +export default async function CountriesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
33 + const sp = await searchParams;
34 + const params: Params = { sort: first(sp.sort) };
35 + const sortDef = SORTS.find((x) => (x.value ?? '') === (params.sort ?? '')) ?? SORTS[0]!;
36 + const res = await safe(api.countries(params.sort ?? 'active'));
37 + const rows = res?.data ?? [];
38 + const base = routes.countries();
39 +
40 + const metricKey = sortDef.key === 'name' ? 'active_payloads' : sortDef.key;
41 + const metricNoun = sortDef.key === 'name' ? 'active payloads' : sortDef.noun;
42 + const top10 = rows.length ? [...rows].sort((a, b) => (num(b[metricKey] as never) ?? 0) - (num(a[metricKey] as never) ?? 0)).slice(0, 10).map((c) => ({ label: c.name, value: num(c[metricKey] as never) ?? 0, href: routes.country(c.slug) })) : [];
43 + const byRegion = new Map<string, number>();
44 + for (const c of rows) byRegion.set(c.region ?? 'Unattributed', (byRegion.get(c.region ?? 'Unattributed') ?? 0) + (num(c[metricKey] as never) ?? 0));
45 + const regionData = [...byRegion.entries()].map(([label, value], i) => ({ label, value, color: `var(--series-${(i % 8) + 1})` })).sort((a, b) => b.value - a.value);
46 +
47 + const totals = rows.reduce(
48 + (t, c) => ({ active: t.active + (num(c.active_payloads) ?? 0), objects: t.objects + (num(c.objects_on_orbit) ?? 0), debris: t.debris + (num(c.debris_on_orbit) ?? 0) }),
49 + { active: 0, objects: 0, debris: 0 },
50 + );
51 +
52 + return (
53 + <Container wide>
54 + <PageHeader
55 + eyebrow="Countries"
56 + title="Countries in orbit"
57 + lede={
58 + res ? (
59 + <>
60 + {fmtInt(rows.length)} countries and jurisdictions hold catalogued objects in orbit — together {fmtInt(totals.active)} active payloads, {fmtInt(totals.objects)} objects on orbit and {fmtInt(totals.debris)} tracked debris fragments. Attribution follows SATCAT owner codes mapped to ISO 3166 (joint programmes are listed under their lead entry); see the{' '}
61 + <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.
62 + </>
63 + ) : (
64 + 'Country rankings are temporarily unavailable.'
65 + )
66 + }
67 + />
68 +
69 + <div className="border-y border-rule py-2">
70 + <ChipRow label="Rank by" paramKey="sort" base={base} params={params} current={params.sort} options={SORTS.map((s) => ({ value: s.value, label: s.label }))} />
71 + </div>
72 +
73 + {res && rows.length > 0 && (
74 + <div className="grid gap-8 border-b border-rule py-8 lg:grid-cols-2">
75 + <div>
76 + <p className="eyebrow mb-3">Top 10 · {metricNoun}</p>
77 + <HBars data={top10} barHeight={30} />
78 + </div>
79 + <div>
80 + <p className="eyebrow mb-3">By region · {metricNoun}</p>
81 + <HBars data={regionData} barHeight={30} />
82 + <p className="mt-3 text-xs text-ink-3">Regions aggregate the countries listed below; multinational organisations (e.g. ESA, Intelsat) appear as their own entry.</p>
83 + </div>
84 + </div>
85 + )}
86 +
87 + <div className="py-6">{!res ? <Unavailable what="Country rankings" /> : <CountryTable rows={rows} />}</div>
88 + </Container>
89 + );
90 +}
91 +
92 +function CountryTable({ rows }: { rows: CountryRow[] }) {
93 + return (
94 + <ScrollTable>
95 + <table className="data-table stack">
96 + <thead>
97 + <tr>
98 + <th className="num">#</th>
99 + <th>Country</th>
100 + <th>Region</th>
101 + <th className="num">Active payloads</th>
102 + <th className="num max-md:hidden!">Payloads on orbit</th>
103 + <th className="num">Objects on orbit</th>
104 + <th className="num">Debris</th>
105 + <th className="num max-md:hidden!">Rocket bodies</th>
106 + <th className="num">Launches</th>
107 + <th className="num max-md:hidden!">Operators</th>
108 + <th className="num">Payloads 365 d</th>
109 + </tr>
110 + </thead>
111 + <tbody>
112 + {rows.length === 0 && <EmptyRow colSpan={11}>No countries returned.</EmptyRow>}
113 + {rows.map((c, i) => (
114 + <tr key={c.code}>
115 + <td data-label="Rank" className="num mono text-xs text-ink-3">{i + 1}</td>
116 + <td className="primary" data-label="Country">
117 + <Link href={routes.country(c.slug)} className="link font-medium">{c.name}</Link>
118 + <span className="mono ml-2 text-xs text-ink-3">{c.iso3 ?? c.code}</span>
119 + </td>
120 + <td data-label="Region" className="text-ink-2">{c.region ?? '—'}</td>
121 + <td data-label="Active payloads" className="num tnum font-medium text-active">{fmtInt(c.active_payloads)}</td>
122 + <td data-label="Payloads on orbit" className="num tnum max-md:hidden!">{fmtInt(c.on_orbit_payloads)}</td>
123 + <td data-label="Objects on orbit" className="num tnum">{fmtInt(c.objects_on_orbit)}</td>
124 + <td data-label="Debris" className="num tnum text-warn">{fmtInt(c.debris_on_orbit)}</td>
125 + <td data-label="Rocket bodies" className="num tnum max-md:hidden!">{fmtInt(c.rocket_bodies_on_orbit)}</td>
126 + <td data-label="Launches" className="num tnum">{fmtInt(c.launches)}</td>
127 + <td data-label="Operators" className="num tnum max-md:hidden!">{fmtInt(c.operators)}</td>
128 + <td data-label="Payloads 365 d" className="num tnum">{fmtInt(c.payloads_last_365d)}</td>
129 + </tr>
130 + ))}
131 + </tbody>
132 + </table>
133 + </ScrollTable>
134 + );
135 +}
added apps/web/src/app/country/[slug]/page.tsx +245 −0
@@ -0,0 +1,245 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Bars, Donut, HBars, StackedBars } from '@/components/charts/charts';
5 +import { Block, EventsList, HeroFacts, KpiStrip, PlannedUnavailable, ScrollTable, Tag, entityMetadata } from '@/components/entities/shared';
6 +import { ConstellationsMiniTable, LaunchesTable, OperatorsMiniTable, SatellitesTable } from '@/components/entities/tables';
7 +import { WorldMap } from '@/components/map/world-map';
8 +import { Container } from '@/components/ui/section';
9 +import { Unavailable } from '@/components/ui/unavailable';
10 +import { ApiError, api, safe } from '@/lib/api';
11 +import { fmtDate, fmtDateTime, fmtInt, num, titleCase } from '@/lib/format';
12 +import { MISSION_LABELS, OBJECT_TYPE_LABELS, ORBIT_CLASS_COLORS, SITE_URL, routes } from '@/lib/site';
13 +import type { CountryDetail } from '@/lib/types';
14 +
15 +type Props = { params: Promise<{ slug: string }> };
16 +
17 +async function load(slug: string): Promise<{ d: CountryDetail; generatedAt: string } | null> {
18 + try {
19 + const res = await api.country(slug);
20 + return { d: res.data, generatedAt: res.meta.generated_at };
21 + } catch (e) {
22 + if (e instanceof ApiError && e.notFound) return null;
23 + throw e;
24 + }
25 +}
26 +
27 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
28 + const { slug } = await params;
29 + const r = await load(slug).catch(() => null);
30 + if (!r) return { title: 'Country not found', robots: { index: false } };
31 + const { d } = r;
32 + return entityMetadata({
33 + title: `${d.name} in orbit — ${fmtInt(d.active_payloads)} active satellites, ${fmtInt(d.objects_on_orbit)} objects`,
34 + description: `${d.name}: ${fmtInt(d.active_payloads)} active payloads, ${fmtInt(d.objects_on_orbit)} objects on orbit including ${fmtInt(d.debris_on_orbit)} debris fragments, ${fmtInt(d.launches)} launches and ${fmtInt(d.operators)} operators. Rankings, orbital and mission distribution, growth, launch sites and recent launches.`,
35 + path: routes.country(d.slug),
36 + });
37 +}
38 +
39 +function Rank({ n, of }: { n: unknown; of: number | null }) {
40 + const v = num(n as never);
41 + if (v === null) return <>—</>;
42 + return (
43 + <>
44 + #{v}
45 + {of !== null && <span className="text-ink-3"> of {of}</span>}
46 + </>
47 + );
48 +}
49 +
50 +export default async function CountryPage({ params }: Props) {
51 + const { slug } = await params;
52 + const [r, all] = await Promise.all([load(slug), safe(api.countries())]);
53 + if (!r) notFound();
54 + const { d, generatedAt } = r;
55 + const nCountries = all?.data.length ?? null;
56 +
57 + const orbitData = d.orbit_distribution.map((o) => ({ label: o.orbit_class, value: num(o.count) ?? 0, color: ORBIT_CLASS_COLORS[o.orbit_class] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);
58 + const missionData = d.mission_distribution.map((m) => ({ label: MISSION_LABELS[m.mission_type] ?? titleCase(m.mission_type), value: num(m.count) ?? 0 })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);
59 + const payloadGrowth = d.growth.map((g) => {
60 + const p = num(g.payloads) ?? 0;
61 + const still = Math.min(p, num(g.still_active) ?? 0);
62 + return { x: g.year, still_active: still, retired: Math.max(0, p - still) };
63 + });
64 + const launchGrowth = d.growth.map((g) => ({ x: g.year, y: num(g.launches) ?? 0 }));
65 + const sites = d.launch_sites.filter((s) => s.latitude !== null && s.longitude !== null);
66 + const objectTypes = [...d.object_type_distribution].sort((a, b) => (num(b.on_orbit) ?? 0) - (num(a.on_orbit) ?? 0));
67 +
68 + const jsonLd = {
69 + '@context': 'https://schema.org',
70 + '@type': 'Country',
71 + name: d.name,
72 + identifier: d.iso3 ?? d.code,
73 + url: `${SITE_URL}${routes.country(d.slug)}`,
74 + };
75 +
76 + return (
77 + <Container wide>
78 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
79 +
80 + <header className="pb-6 pt-8 md:pb-8 md:pt-12">
81 + <nav aria-label="Breadcrumb" className="eyebrow">
82 + <Link href={routes.countries()} className="hover:text-ink">Countries</Link> <span aria-hidden>/</span> {d.name}
83 + </nav>
84 + <div className="mt-3 flex flex-wrap items-center gap-2">
85 + <Tag tone="accent">{d.code}</Tag>
86 + {d.iso3 && <Tag>{d.iso3}</Tag>}
87 + {d.region && <Tag>{d.region}</Tag>}
88 + </div>
89 + <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>
90 + <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">
91 + {fmtInt(d.active_payloads)} active payloads · {fmtInt(d.objects_on_orbit)} objects on orbit · {fmtInt(d.operators)} operators
92 + </p>
93 + <HeroFacts
94 + items={[
95 + { label: 'Rank · active payloads', value: <span className="tnum font-medium"><Rank n={d.rank_active} of={nCountries} /></span> },
96 + { label: 'Rank · objects on orbit', value: <span className="tnum font-medium"><Rank n={d.rank_objects} of={nCountries} /></span> },
97 + { label: 'Rank · debris on orbit', value: <span className="tnum font-medium"><Rank n={d.rank_debris} of={nCountries} /></span> },
98 + ]}
99 + />
100 + {nCountries === null && <p className="mt-2 text-xs text-ink-3">Country total unavailable — ranks shown without denominator.</p>}
101 + </header>
102 +
103 + <KpiStrip
104 + items={[
105 + { label: 'Active payloads', value: <span className="text-active">{fmtInt(d.active_payloads)}</span> },
106 + { label: 'Payloads on orbit', value: fmtInt(d.on_orbit_payloads) },
107 + { label: 'Total payloads', value: fmtInt(d.total_payloads) },
108 + { label: 'Objects on orbit', value: fmtInt(d.objects_on_orbit) },
109 + { label: 'Debris on orbit', value: <span className="text-warn">{fmtInt(d.debris_on_orbit)}</span> },
110 + { label: 'Rocket bodies', value: fmtInt(d.rocket_bodies_on_orbit) },
111 + { label: 'Total objects', value: fmtInt(d.total_objects) },
112 + { label: 'Launches', value: fmtInt(d.launches) },
113 + { label: 'Operators', value: fmtInt(d.operators) },
114 + { label: 'Payloads 365 d', value: fmtInt(d.payloads_last_365d) },
115 + { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },
116 + ]}
117 + />
118 +
119 + <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
120 + <div className="min-w-0">
121 + <Block eyebrow="Growth" title="Growth by year" id="growth">
122 + {d.growth.length ? (
123 + <div className="grid gap-6 lg:grid-cols-2">
124 + <div>
125 + <p className="eyebrow mb-2">Payloads launched · still active vs no longer active</p>
126 + <StackedBars data={payloadGrowth} keys={['still_active', 'retired']} labels={{ still_active: 'Still active', retired: 'No longer active' }} title="Payloads by launch year" height={190} />
127 + </div>
128 + <div>
129 + <p className="eyebrow mb-2">Launches per year</p>
130 + <Bars data={launchGrowth} title="Launches per year" height={190} color="var(--series-4)" highlightLast />
131 + </div>
132 + </div>
133 + ) : (
134 + <Unavailable what="Growth history" />
135 + )}
136 + </Block>
137 + <Block eyebrow="Organisations" title={`Operators · ${fmtInt(d.operators)}`} id="operators" action={{ href: `${routes.operators()}?country=${encodeURIComponent(d.slug)}`, label: 'All operators' }}>
138 + <OperatorsMiniTable rows={d.operators_list} />
139 + </Block>
140 + <Block eyebrow="Programmes" title="Constellations" id="constellations">
141 + <ConstellationsMiniTable rows={d.constellations_list} />
142 + </Block>
143 + <Block eyebrow="Ground" title="Launch sites" id="launch-sites" action={{ href: routes.launchSites(), label: 'All launch sites' }}>
144 + {d.launch_sites.length === 0 ? (
145 + <p className="text-sm text-ink-3">No launch site on this country's territory in the catalogue; its payloads fly from foreign sites (see recent launches).</p>
146 + ) : (
147 + <div className="space-y-4">
148 + {sites.length > 0 && <WorldMap title={`Launch sites in ${d.name}`} markers={sites.map((s) => ({ lat: s.latitude!, lon: s.longitude!, label: s.code, href: routes.launchSite(s.slug), size: 4 + Math.min(6, Math.log10((num(s.launches) ?? 1) + 1) * 2) }))} />}
149 + <ScrollTable>
150 + <table className="data-table stack">
151 + <thead>
152 + <tr>
153 + <th>Site</th>
154 + <th>Code</th>
155 + <th className="num">Launches</th>
156 + <th>Last launch</th>
157 + </tr>
158 + </thead>
159 + <tbody>
160 + {d.launch_sites.map((s) => (
161 + <tr key={s.code}>
162 + <td className="primary" data-label="Site"><Link href={routes.launchSite(s.slug)} className="link">{s.name}</Link></td>
163 + <td data-label="Code" className="mono text-xs text-ink-2">{s.code}</td>
164 + <td data-label="Launches" className="num tnum">{fmtInt(s.launches)}</td>
165 + <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(s.last_launch)}</td>
166 + </tr>
167 + ))}
168 + </tbody>
169 + </table>
170 + </ScrollTable>
171 + </div>
172 + )}
173 + </Block>
174 + <Block eyebrow="Fleet" title="Recent satellites" id="satellites" action={{ href: routes.satellites(`country=${encodeURIComponent(d.slug)}`), label: 'All satellites' }}>
175 + <SatellitesTable rows={d.recent_satellites} columns={['type', 'orbit']} />
176 + </Block>
177 + <Block eyebrow="Launches" title="Recent launches" id="launches" action={{ href: routes.launches(`country=${encodeURIComponent(d.slug)}`), label: 'All launches' }}>
178 + <LaunchesTable rows={d.recent_launches} showPrimary />
179 + </Block>
180 + <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(), label: 'All events' }}>
181 + <EventsList events={d.events} />
182 + </Block>
183 + </div>
184 +
185 + <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">
186 + <Block eyebrow="Payloads on orbit" title={<span className="inline-flex items-center gap-2">Orbital distribution <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>
187 + <Donut data={orbitData} title="Payloads on orbit by orbit class" size={140} />
188 + </Block>
189 + <Block eyebrow="Payloads" title={<span className="inline-flex items-center gap-2">Mission distribution <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>
190 + <HBars data={missionData} />
191 + </Block>
192 + <Block eyebrow="Catalogue" title="Object types">
193 + {objectTypes.length === 0 ? (
194 + <Unavailable what="Object type breakdown" compact />
195 + ) : (
196 + <ScrollTable>
197 + <table className="data-table">
198 + <thead>
199 + <tr>
200 + <th>Type</th>
201 + <th className="num">On orbit</th>
202 + <th className="num">Total</th>
203 + </tr>
204 + </thead>
205 + <tbody>
206 + {objectTypes.map((o) => (
207 + <tr key={o.object_type}>
208 + <td>{OBJECT_TYPE_LABELS[o.object_type] ?? titleCase(o.object_type)}</td>
209 + <td className="num tnum font-medium">{fmtInt(o.on_orbit)}</td>
210 + <td className="num tnum text-ink-2">{fmtInt(o.total)}</td>
211 + </tr>
212 + ))}
213 + </tbody>
214 + </table>
215 + </ScrollTable>
216 + )}
217 + </Block>
218 + <Block eyebrow="Attribution" title="Owner codes">
219 + {d.owner_codes.length === 0 ? (
220 + <Unavailable what="Owner code mapping" compact />
221 + ) : (
222 + <ul className="divide-y divide-rule text-sm">
223 + {d.owner_codes.map((o) => (
224 + <li key={o.code} className="flex items-center justify-between gap-3 py-2">
225 + <span className="min-w-0 truncate text-ink-2">{o.name}</span>
226 + <span className="flex shrink-0 items-center gap-2">
227 + <Tag>{o.kind}</Tag>
228 + <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{o.code}</code>
229 + </span>
230 + </li>
231 + ))}
232 + </ul>
233 + )}
234 + <p className="mt-3 text-xs text-ink-3">
235 + SATCAT owner codes mapped to this country. Joint programmes are attributed to the lead country; the mapping is versioned in the <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.
236 + </p>
237 + </Block>
238 + <Block eyebrow="Regulatory" title="Registrations & licenses">
239 + <PlannedUnavailable what="Registrations & licenses" note="UNOOSA register and national regulatory connectors are planned; entries will carry their source once ingested." />
240 + </Block>
241 + </aside>
242 + </div>
243 + </Container>
244 + );
245 +}
added apps/web/src/app/debris/page.tsx +183 −0
@@ -0,0 +1,183 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { HBars, StackedBars } from '@/components/charts/charts';
4 +import { ChartBlock, Disclaimer, InlineRank, Note, StatGrid, TwoCol } from '@/components/stats/shared';
5 +import { TypeBadge } from '@/components/ui/badges';
6 +import { Container, PageHeader, Section, Stat } from '@/components/ui/section';
7 +import { Unavailable } from '@/components/ui/unavailable';
8 +import { api, safe } from '@/lib/api';
9 +import { fmt2, fmtAgo, fmtDate, fmtInt, fmtKm, num } from '@/lib/format';
10 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
11 +
12 +export const revalidate = 900;
13 +
14 +const TITLE = 'Space debris — catalogued fragments, rocket bodies and their sources';
15 +const DESC = 'Tracked space debris on orbit: totals, debris by country, altitude distribution, fragmentation events with the most fragments still on orbit, debris growth by launch year and the largest tracked objects. Catalogued object counts only.';
16 +
17 +export async function generateMetadata(): Promise<Metadata> {
18 + const url = `${SITE_URL}${routes.debris()}`;
19 + return {
20 + title: TITLE,
21 + description: DESC,
22 + alternates: { canonical: url },
23 + openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url, type: 'website', siteName: SITE_NAME },
24 + twitter: { card: 'summary_large_image', title: `${TITLE} | ${SITE_NAME}`, description: DESC },
25 + };
26 +}
27 +
28 +export default async function DebrisPage() {
29 + const res = await safe(api.debris());
30 + const d = res?.data ?? null;
31 +
32 + const byCountry = d ? [...d.by_country].sort((a, b) => (num(b.debris) ?? 0) - (num(a.debris) ?? 0)) : [];
33 + const debrisBars = byCountry.filter((c) => (num(c.debris) ?? 0) > 0).slice(0, 12).map((c) => ({ label: c.name, value: num(c.debris) ?? 0, color: 'var(--series-4)' }));
34 + const rbBars = [...byCountry].sort((a, b) => (num(b.rocket_bodies) ?? 0) - (num(a.rocket_bodies) ?? 0)).filter((c) => (num(c.rocket_bodies) ?? 0) > 0).slice(0, 12).map((c) => ({ label: c.name, value: num(c.rocket_bodies) ?? 0, color: 'var(--series-2)' }));
35 +
36 + const byAlt = d ? [...d.by_altitude].sort((a, b) => (num(a.alt_km) ?? 0) - (num(b.alt_km) ?? 0)).map((r) => ({ x: String(num(r.alt_km) ?? 0), debris: num(r.debris) ?? 0, rocket_bodies: num(r.rocket_bodies) ?? 0 })) : [];
37 + const growth = d ? [...d.debris_growth].sort((a, b) => a.year - b.year).map((r) => {
38 + const cat = num(r.debris_catalogued) ?? 0;
39 + const still = num(r.debris_still_on_orbit) ?? 0;
40 + return { x: r.year, still_on_orbit: still, decayed: Math.max(0, cat - still) };
41 + }) : [];
42 + const decays = d ? [...d.decays_by_year].sort((a, b) => a.year - b.year).map((r) => ({ x: r.year, payloads: num(r.payloads) ?? 0, debris: num(r.debris) ?? 0, rocket_bodies: num(r.rocket_bodies) ?? 0 })) : [];
43 + const largest = d ? [...d.largest_objects].sort((a, b) => (num(b.rcs_m2) ?? 0) - (num(a.rcs_m2) ?? 0)) : [];
44 +
45 + return (
46 + <Container>
47 + <PageHeader eyebrow="Debris" title="Space debris on orbit" lede="Catalogued fragments and spent rocket bodies currently tracked, where they come from and how the population has evolved since 1957. Counts come from the SATCAT as normalised by SatelliteIndex.">
48 + {res && <p className="mono mt-4 text-xs text-ink-3">Generated {fmtAgo(res.meta.generated_at)}</p>}
49 + </PageHeader>
50 +
51 + {!d ? (
52 + <div className="py-6">
53 + <Unavailable what="Debris statistics" />
54 + </div>
55 + ) : (
56 + <>
57 + <div className="pb-8">
58 + <Disclaimer text={d.disclaimer} />
59 + </div>
60 +
61 + <Section eyebrow="Totals" title="Non-operational objects on orbit" className="pt-0 md:pt-0">
62 + <StatGrid cols={4}>
63 + <Stat label="Debris" value={fmtInt(d.totals.debris)} accent hint="Catalogued fragments on orbit" />
64 + <Stat label="Rocket bodies" value={fmtInt(d.totals.rocket_bodies)} hint="Spent stages on orbit" />
65 + <Stat label="Unknown type" value={fmtInt(d.totals.unknown)} hint="Unclassified objects on orbit" />
66 + <Stat label="Inactive payloads" value={fmtInt(d.totals.inactive_payloads)} hint="Non-operational satellites on orbit" />
67 + </StatGrid>
68 + </Section>
69 +
70 + <Section eyebrow="By country" title="Who the debris is attributed to" action={{ href: routes.rankings('countries-debris'), label: 'Full ranking' }}>
71 + <TwoCol>
72 + <ChartBlock title="Debris on orbit by country" hint="SATCAT owner code → country">
73 + {debrisBars.length ? <HBars data={debrisBars} /> : <Unavailable what="Debris by country" />}
74 + <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
75 + {byCountry.slice(0, 12).map((c) => (
76 + <li key={c.code}>
77 + <Link href={routes.country(c.slug)} className="link">{c.name}</Link>
78 + </li>
79 + ))}
80 + </ul>
81 + </ChartBlock>
82 + <ChartBlock title="Rocket bodies on orbit by country">{rbBars.length ? <HBars data={rbBars} /> : <Unavailable what="Rocket bodies by country" />}</ChartBlock>
83 + </TwoCol>
84 + </Section>
85 +
86 + <Section eyebrow="Altitude" title="Where debris and rocket bodies orbit">
87 + <ChartBlock title="Debris and rocket bodies by perigee altitude" hint="50 km bins" derived>
88 + <StackedBars data={byAlt} keys={['debris', 'rocket_bodies']} labels={{ debris: 'Debris', rocket_bodies: 'Rocket bodies' }} title="Debris and rocket bodies on orbit by perigee altitude, 50 km bins (km)" height={220} xTicks={10} />
89 + <Note className="mt-2">x-axis = lower edge of each 50 km perigee bin (km). Objects without a current element set are not placed. Informational density only.</Note>
90 + </ChartBlock>
91 + </Section>
92 +
93 + <Section eyebrow="Sources" title="Launches with the most fragments still on orbit" action={{ href: routes.launches(), label: 'All launches' }}>
94 + {d.by_launch.length === 0 ? (
95 + <Unavailable what="Fragmentation sources" />
96 + ) : (
97 + <table className="data-table stack text-sm">
98 + <thead>
99 + <tr>
100 + <th>Launch</th>
101 + <th>Date</th>
102 + <th>Site</th>
103 + <th>Owners</th>
104 + <th className="num">Debris on orbit</th>
105 + </tr>
106 + </thead>
107 + <tbody>
108 + {d.by_launch.map((l, i) => (
109 + <tr key={l.cospar_launch_id}>
110 + <td className="primary">
111 + <InlineRank n={i + 1} />
112 + <Link href={routes.launch(l.cospar_launch_id)} className="link font-medium">{l.primary_name ?? l.cospar_launch_id}</Link>
113 + <span className="mono ml-2 text-xs text-ink-3">{l.cospar_launch_id}</span>
114 + </td>
115 + <td data-label="Date" className="mono text-xs">{fmtDate(l.launch_date)}</td>
116 + <td data-label="Site" className="text-ink-2">{l.site_name ?? '—'}</td>
117 + <td data-label="Owners" className="mono text-xs">{l.owner_codes?.join(', ') || '—'}</td>
118 + <td data-label="Debris on orbit" className="num tnum font-medium">{fmtInt(l.debris_on_orbit)}</td>
119 + </tr>
120 + ))}
121 + </tbody>
122 + </table>
123 + )}
124 + <Note className="mt-3">Fragments are attributed to the COSPAR launch of their parent object. Owner codes are SATCAT designators (e.g. PRC, CIS, US).</Note>
125 + </Section>
126 +
127 + <Section eyebrow="History" title="Debris growth and decay">
128 + <TwoCol>
129 + <ChartBlock title="Debris catalogued by launch year" hint="Still on orbit vs decayed">
130 + <StackedBars data={growth} keys={['still_on_orbit', 'decayed']} labels={{ still_on_orbit: 'Still on orbit', decayed: 'Decayed' }} title="Debris catalogued by launch year of the parent object: still on orbit vs decayed" height={220} xTicks={7} />
131 + <Note className="mt-2">Decayed = catalogued − still on orbit (derived from the two published series). Year = launch year of the parent object, not the fragmentation date.</Note>
132 + </ChartBlock>
133 + <ChartBlock title="Decays per year by object type">
134 + <StackedBars data={decays} keys={['payloads', 'debris', 'rocket_bodies']} labels={{ payloads: 'Payloads', debris: 'Debris', rocket_bodies: 'Rocket bodies' }} title="Objects decayed per year by type" height={220} xTicks={7} />
135 + <Note className="mt-2">
136 + Published SATCAT decay dates. Recent reentries are listed on <Link href={routes.reentries()} className="text-accent hover:underline">/reentries</Link>.
137 + </Note>
138 + </ChartBlock>
139 + </TwoCol>
140 + </Section>
141 +
142 + <Section eyebrow="Largest" title="Largest tracked debris and rocket bodies" action={{ href: routes.satellites('object_type=DEBRIS,ROCKET_BODY&on_orbit=true'), label: 'Browse all' }}>
143 + {largest.length === 0 ? (
144 + <Unavailable what="Largest objects" />
145 + ) : (
146 + <table className="data-table stack text-sm">
147 + <thead>
148 + <tr>
149 + <th>Object</th>
150 + <th>Type</th>
151 + <th className="num">RCS (m²)</th>
152 + <th className="num">Perigee</th>
153 + <th className="num">Apogee</th>
154 + <th>Launched</th>
155 + <th>Country</th>
156 + </tr>
157 + </thead>
158 + <tbody>
159 + {largest.map((o, i) => (
160 + <tr key={o.id}>
161 + <td className="primary">
162 + <InlineRank n={i + 1} />
163 + <Link href={routes.satellite(o.slug)} className="link font-medium">{o.name}</Link>
164 + {o.norad_id && <span className="mono ml-2 text-xs text-ink-3">#{o.norad_id}</span>}
165 + </td>
166 + <td data-label="Type"><TypeBadge type={o.object_type} /></td>
167 + <td data-label="RCS (m²)" className="num tnum">{fmt2(o.rcs_m2)}</td>
168 + <td data-label="Perigee" className="num tnum">{fmtKm(o.perigee_km)}</td>
169 + <td data-label="Apogee" className="num tnum">{fmtKm(o.apogee_km)}</td>
170 + <td data-label="Launched" className="mono text-xs">{fmtDate(o.launch_date)}</td>
171 + <td data-label="Country" className="mono text-xs">{o.country_code ?? '—'}</td>
172 + </tr>
173 + ))}
174 + </tbody>
175 + </table>
176 + )}
177 + <Note className="mt-3">RCS = radar cross-section as published in the SATCAT (m²); it is a size proxy, not mass. {d.disclaimer}</Note>
178 + </Section>
179 + </>
180 + )}
181 + </Container>
182 + );
183 +}
added apps/web/src/app/developers/page.tsx +172 −0
@@ -0,0 +1,172 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ENDPOINT_GROUPS, RATE_LIMITS } from '@/components/meta/endpoints';
4 +import { ATTRIBUTION, DISCLAIMER } from '@/components/meta/legal';
5 +import { Callout, Code, DocLayout, DocSection, Prose } from '@/components/meta/prose';
6 +import { Container, PageHeader } from '@/components/ui/section';
7 +import { CONTACT_EMAIL, SITE_URL, routes } from '@/lib/site';
8 +
9 +export const metadata: Metadata = {
10 + title: 'API for developers — free JSON access to the orbital catalog',
11 + description: 'The SatelliteIndex REST API: satellites, live SGP4 positions, constellations, operators, countries, launches, debris, reentries, events and statistics as JSON. Free during the MVP.',
12 + alternates: { canonical: `${SITE_URL}/developers` },
13 + openGraph: { title: 'API | SatelliteIndex', description: 'Free JSON API for everything in Earth orbit — the same API that powers this site.', url: `${SITE_URL}/developers` },
14 + twitter: { card: 'summary', title: 'API | SatelliteIndex', description: 'Free JSON API for everything in Earth orbit.' },
15 +};
16 +
17 +const BASE = `${SITE_URL}/api/v1`;
18 +
19 +const TOC = [
20 + { id: 'basics', label: 'Basics' },
21 + { id: 'envelope', label: 'Response envelope' },
22 + ...ENDPOINT_GROUPS.map((g) => ({ id: `ep-${g.title.toLowerCase().replace(/[^a-z]+/g, '-')}`, label: g.title })),
23 + { id: 'positions-format', label: 'Batch positions format' },
24 + { id: 'rate-limits', label: 'Rate limits' },
25 + { id: 'attribution', label: 'Attribution & terms' },
26 + { id: 'roadmap', label: 'Keys and tiers' },
27 +];
28 +
29 +export default function DevelopersPage() {
30 + return (
31 + <Container>
32 + <PageHeader eyebrow="Developers" title="The SatelliteIndex API" lede="Every page on this site is rendered from the public JSON API documented here — there is no private dataset behind it. Free, no key required during the MVP, rate-limited per client.">
33 + <p className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
34 + <a href="/api/v1/docs" className="link">
35 + Interactive docs (Swagger)
36 + </a>
37 + <a href="/api/v1/openapi.json" className="link">
38 + OpenAPI 3 schema
39 + </a>
40 + <Link href={routes.status()} className="link">
41 + Status
42 + </Link>
43 + </p>
44 + </PageHeader>
45 +
46 + <DocLayout toc={TOC}>
47 + <div>
48 + <DocSection id="basics" title="Basics">
49 + <Prose>
50 + <p>
51 + Base URL: <code>{BASE}</code>. All endpoints are <code>GET</code>, return <code>application/json</code> encoded in UTF-8, and use UTC ISO-8601 timestamps. Units are kilometres, km/s, kilograms, degrees and minutes. Countries are ISO 3166-1 alpha-2 codes. Numbers aggregated by the database may arrive as strings — parse them as decimals.
52 + </p>
53 + <p>
54 + Satellites can be addressed by slug (<code>iss-zarya-25544</code>), NORAD catalog number (<code>25544</code>) or COSPAR designator (<code>1998-067A</code>). Internal ids are prefixed ULIDs (<code>sat_…</code>) and are stable; NORAD and COSPAR are source identifiers and are kept as such.
55 + </p>
56 + </Prose>
57 + <Code label="curl">{`curl -s "${BASE}/satellites/25544" | jq '.data | {name, norad_id, status, orbit_class, live}'`}</Code>
58 + </DocSection>
59 +
60 + <DocSection id="envelope" title="Response envelope and pagination">
61 + <Prose>
62 + <p>
63 + Single resources return <code>{'{ data, meta }'}</code>; list endpoints add a <code>pagination</code> block. <code>meta.request_id</code> is the id you will find in our server logs if you report a problem; <code>meta.generated_at</code> is the server time of the response (responses may be cached for up to a few minutes).
64 + </p>
65 + </Prose>
66 + <Code label="Shape">{`{
67 + "data": [ … ],
68 + "pagination": { "page": 1, "page_size": 50, "total": 17026, "pages": 341 },
69 + "meta": { "request_id": "b382117229134a57", "generated_at": "2026-09-11T18:17:53Z" }
70 +}`}</Code>
71 + <Prose className="mt-3">
72 + <p>
73 + Errors use the same envelope with an <code>error</code> object: <code>{'{ "error": { "title", "detail", "status" } }'}</code> and the matching HTTP status (404 unknown entity, 422 invalid parameter, 429 rate limited, 503 upstream/database unavailable). Paginate with <code>page</code> and <code>page_size</code> (1–200; sitemap feeds allow more).
74 + </p>
75 + </Prose>
76 + </DocSection>
77 +
78 + {ENDPOINT_GROUPS.map((g) => (
79 + <DocSection key={g.title} id={`ep-${g.title.toLowerCase().replace(/[^a-z]+/g, '-')}`} title={g.title} eyebrow="Endpoints">
80 + <ul className="divide-y divide-rule border-y border-rule">
81 + {g.endpoints.map((e) => (
82 + <li key={e.path} className="py-3">
83 + <div className="flex flex-wrap items-center gap-2">
84 + <span className="mono rounded bg-plane-3 px-1.5 py-0.5 text-[11px] text-accent">{e.method}</span>
85 + <code className="mono break-all text-sm text-ink">{e.path}</code>
86 + {e.bucket && <span className="text-[11px] text-ink-3">rate bucket: {e.bucket}</span>}
87 + </div>
88 + <p className="mt-1 text-sm text-ink-2">{e.summary}</p>
89 + <Code className="mt-2">{`curl -s "${BASE}${e.example}"`}</Code>
90 + </li>
91 + ))}
92 + </ul>
93 + </DocSection>
94 + ))}
95 +
96 + <DocSection id="positions-format" title="Batch positions format">
97 + <Prose>
98 + <p>
99 + <code>/orbit/positions</code> returns the position of every object in the propagator (tens of thousands) at two instants, <code>t0</code> and <code>t1 = t0 + step_s</code>, so a client can interpolate smoothly between polls. To keep the payload small the response is a set of <strong>parallel arrays</strong> rather than one object per satellite: index <code>i</code> of each array describes the same object.
100 + </p>
101 + </Prose>
102 + <Code label="Fields">{`{
103 + "t0": "…", "t1": "…", "step_s": 60, "count": N, "total_tracked": N,
104 + "fields": ["norad", "cls", "mission", "active", "pos", "vel"],
105 + "norad": [25544, …], // NORAD id per object
106 + "cls": [0, …], // index into legend.cls (orbit class)
107 + "mission": [8, …], // index into legend.mission
108 + "active": [1, …], // 1 = ACTIVE status
109 + "pos": [lat0, lon0, alt0, lat1, lon1, alt1, …], // 6 numbers per object (deg, deg, km)
110 + "vel": [v0, v1, …], // km/s at t0 and t1
111 + "legend": { "cls": ["LEO","MEO","GEO","HEO","OTHER"], "mission": [ … ] }
112 +}`}</Code>
113 + <Prose className="mt-3">
114 + <p>Positions are derived from the latest element set of each object and inherit its age; check <code>/sources/status</code> for the median element age before relying on them.</p>
115 + </Prose>
116 + </DocSection>
117 +
118 + <DocSection id="rate-limits" title="Rate limits">
119 + <Prose>
120 + <p>Limits are applied per client IP over a sliding 60-second window. Exceeding a bucket returns <code>429 Too Many Requests</code> with the bucket name in the error detail. Cache responses on your side; most datasets change hourly at most.</p>
121 + </Prose>
122 + <div className="mt-4 overflow-x-auto">
123 + <table className="data-table stack sm:min-w-[480px]">
124 + <thead>
125 + <tr>
126 + <th>Bucket</th>
127 + <th className="num">Requests / min</th>
128 + <th>Applies to</th>
129 + </tr>
130 + </thead>
131 + <tbody>
132 + {RATE_LIMITS.map((r) => (
133 + <tr key={r.bucket}>
134 + <td className="primary mono text-sm">{r.bucket}</td>
135 + <td data-label="Requests / min" className="num tnum text-sm">
136 + {r.perMinute}
137 + </td>
138 + <td data-label="Applies to" className="mono text-xs text-ink-2">
139 + {r.applies}
140 + </td>
141 + </tr>
142 + ))}
143 + </tbody>
144 + </table>
145 + </div>
146 + </DocSection>
147 +
148 + <DocSection id="attribution" title="Attribution and terms">
149 + <Prose>
150 + <p>
151 + If you republish data from this API you must keep the upstream attributions. The orbital elements and catalog come from CelesTrak, whose terms require credit: <strong>“Orbital data courtesy of CelesTrak.”</strong> Derived values (orbit class, mission type, constellation membership, activity score, density) should be credited to SatelliteIndex with a link to the <Link href={routes.methodology()} className="link">methodology</Link>. The full source list with licenses is on <Link href={routes.sources()} className="link">/sources</Link>.
152 + </p>
153 + <p>{ATTRIBUTION}</p>
154 + </Prose>
155 + <Callout tone="warn">{DISCLAIMER}</Callout>
156 + <p className="mt-3 text-sm text-ink-3">
157 + Full <Link href={routes.terms()} className="link">terms of use</Link>.
158 + </p>
159 + </DocSection>
160 +
161 + <DocSection id="roadmap" title="Keys and tiers">
162 + <Prose>
163 + <p>
164 + The API is <strong>free during the MVP</strong> and requires no key. API keys, higher rate limits and paid tiers for heavy or commercial use will come later; the public endpoints documented here will keep a free tier. If you are building on the API, say hello at <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a> so we can warn you before any breaking change.
165 + </p>
166 + </Prose>
167 + </DocSection>
168 + </div>
169 + </DocLayout>
170 + </Container>
171 + );
172 +}
added apps/web/src/app/events/[id]/page.tsx +171 −0
@@ -0,0 +1,171 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Confidence, EntityLinks, EventTypeBadge, entityHref, entityName, eventLabel } from '@/components/events/event-badge';
5 +import { Note } from '@/components/stats/shared';
6 +import { Container, PageHeader, Section } from '@/components/ui/section';
7 +import { Unavailable } from '@/components/ui/unavailable';
8 +import { api, ApiError } from '@/lib/api';
9 +import { fmtAgo, fmtDateTime } from '@/lib/format';
10 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
11 +import type { EventRow } from '@/lib/types';
12 +
13 +export const revalidate = 300;
14 +
15 +type Params = Promise<{ id: string }>;
16 +
17 +async function load(id: string): Promise<EventRow | null | 'unavailable'> {
18 + try {
19 + return (await api.event(id)).data;
20 + } catch (e) {
21 + if (e instanceof ApiError && e.notFound) return null;
22 + return 'unavailable';
23 + }
24 +}
25 +
26 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
27 + const { id } = await params;
28 + const ev = await load(id);
29 + const url = `${SITE_URL}/events/${encodeURIComponent(id)}`;
30 + if (!ev || ev === 'unavailable') return { title: 'Event', alternates: { canonical: url }, robots: { index: false } };
31 + const title = `${ev.title} — ${eventLabel(ev.type)}`;
32 + const description = ev.summary ? `${ev.summary.slice(0, 180)}${ev.summary.length > 180 ? '…' : ''}` : `${eventLabel(ev.type)} event detected ${fmtDateTime(ev.event_time)} (source: ${ev.source_name ?? ev.source_id ?? 'unknown'}).`;
33 + return {
34 + title,
35 + description,
36 + alternates: { canonical: url },
37 + openGraph: { title: `${title} | ${SITE_NAME}`, description, url, type: 'article', siteName: SITE_NAME, publishedTime: ev.event_time },
38 + twitter: { card: 'summary_large_image', title: `${title} | ${SITE_NAME}`, description },
39 + };
40 +}
41 +
42 +function Field({ label, children, mono = false }: { label: string; children: React.ReactNode; mono?: boolean }) {
43 + return (
44 + <div className="border-t border-rule py-3">
45 + <p className="eyebrow">{label}</p>
46 + <div className={`mt-1 text-sm text-ink ${mono ? 'mono break-all' : ''}`}>{children}</div>
47 + </div>
48 + );
49 +}
50 +
51 +export default async function EventDetailPage({ params }: { params: Params }) {
52 + const { id } = await params;
53 + const ev = await load(id);
54 + if (ev === null) notFound();
55 + if (ev === 'unavailable') {
56 + return (
57 + <Container>
58 + <PageHeader eyebrow="Event" title="Event" />
59 + <Unavailable what="Event detail" />
60 + </Container>
61 + );
62 + }
63 + const entities = ev.entities ?? [];
64 + const jsonLd = {
65 + '@context': 'https://schema.org',
66 + '@type': 'Event',
67 + name: ev.title,
68 + description: ev.summary ?? undefined,
69 + startDate: ev.event_time,
70 + eventStatus: 'https://schema.org/EventScheduled',
71 + location: { '@type': 'Place', name: 'Earth orbit' },
72 + url: `${SITE_URL}/events/${encodeURIComponent(ev.id)}`,
73 + };
74 +
75 + return (
76 + <Container>
77 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
78 + <PageHeader
79 + eyebrow={
80 + <span className="inline-flex flex-wrap items-center gap-2">
81 + <Link href={routes.events()} className="hover:text-accent">Events</Link>
82 + <span aria-hidden>/</span>
83 + <EventTypeBadge type={ev.type} />
84 + </span>
85 + }
86 + title={ev.title}
87 + lede={ev.summary ?? undefined}
88 + >
89 + <p className="mono mt-4 text-xs text-ink-3">
90 + <time dateTime={ev.event_time}>{fmtDateTime(ev.event_time)}</time> · {fmtAgo(ev.event_time)}
91 + {ev.detected_at && <> · detected {fmtAgo(ev.detected_at)}</>}
92 + </p>
93 + </PageHeader>
94 +
95 + <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_360px] lg:gap-14">
96 + <div className="space-y-10">
97 + <Section eyebrow="Entities" title={`${entities.length} linked ${entities.length === 1 ? 'entity' : 'entities'}`} className="pt-0 md:pt-0">
98 + {entities.length === 0 ? (
99 + <Unavailable what="Linked entities" compact />
100 + ) : (
101 + <table className="data-table stack text-sm">
102 + <thead>
103 + <tr>
104 + <th>Type</th>
105 + <th>Entity</th>
106 + <th>Relationship</th>
107 + <th>NORAD</th>
108 + </tr>
109 + </thead>
110 + <tbody>
111 + {entities.map((e) => {
112 + const href = entityHref(e);
113 + return (
114 + <tr key={`${e.type}-${e.id}`}>
115 + <td data-label="Type" className="mono text-xs uppercase text-ink-3">{e.type.replace('_', ' ')}</td>
116 + <td data-label="Entity" className="primary">
117 + {href ? <Link href={href} className="link font-medium">{entityName(e)}</Link> : <span>{entityName(e)}</span>}
118 + {e.type === 'launch' && e.name && <span className="ml-2 text-xs text-ink-3">{e.name}</span>}
119 + </td>
120 + <td data-label="Relationship" className="text-ink-2">{e.relationship ?? '—'}</td>
121 + <td data-label="NORAD" className="mono text-xs">{e.norad_id ?? '—'}</td>
122 + </tr>
123 + );
124 + })}
125 + </tbody>
126 + </table>
127 + )}
128 + <EntityLinks entities={entities} className="mt-3 md:hidden" max={4} />
129 + </Section>
130 +
131 + <Section eyebrow="Metadata" title="Raw event metadata" className="pt-0 md:pt-0">
132 + {ev.metadata && Object.keys(ev.metadata).length ? (
133 + <pre className="mono scrollbar-thin overflow-x-auto rounded-md border border-rule bg-plane p-4 text-xs leading-relaxed text-ink-2">{JSON.stringify(ev.metadata, null, 2)}</pre>
134 + ) : (
135 + <Unavailable what="Metadata" compact />
136 + )}
137 + <Note className="mt-3">Metadata is stored exactly as produced by the connector that detected the event; keys vary by event type.</Note>
138 + </Section>
139 + </div>
140 +
141 + <aside className="lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:self-start">
142 + <p className="eyebrow mb-1">Telemetry</p>
143 + <Field label="Event id" mono>{ev.id}</Field>
144 + <Field label="Type">
145 + <EventTypeBadge type={ev.type} size="md" />
146 + <span className="mono ml-2 text-xs text-ink-3">{ev.type}</span>
147 + </Field>
148 + <Field label="Event time" mono>{fmtDateTime(ev.event_time)}</Field>
149 + {ev.detected_at && <Field label="Detected at" mono>{fmtDateTime(ev.detected_at)}</Field>}
150 + <Field label="Source">
151 + {ev.source_url ? (
152 + <a href={ev.source_url} target="_blank" rel="noopener noreferrer" className="link">{ev.source_name ?? ev.source_id}</a>
153 + ) : (
154 + <span>{ev.source_name ?? ev.source_id ?? 'Unavailable'}</span>
155 + )}
156 + {ev.source_id && <span className="mono ml-2 text-xs text-ink-3">{ev.source_id}</span>}
157 + </Field>
158 + <Field label="Confidence">
159 + <Confidence value={ev.confidence} className="text-base" />
160 + <span className="ml-2 text-xs text-ink-3">
161 + derived · <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>
162 + </span>
163 + </Field>
164 + <div className="border-t border-rule pt-3">
165 + <Link href={routes.events(`type=${encodeURIComponent(ev.type)}`)} className="text-sm text-accent hover:underline">All {eventLabel(ev.type).toLowerCase()} events →</Link>
166 + </div>
167 + </aside>
168 + </div>
169 + </Container>
170 + );
171 +}
added apps/web/src/app/events/page.tsx +117 −0
@@ -0,0 +1,117 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EventTimeline } from '@/components/events/event-timeline';
4 +import { eventLabel } from '@/components/events/event-badge';
5 +import { Chips, Note } from '@/components/stats/shared';
6 +import { Pagination } from '@/components/ui/pagination';
7 +import { Container, PageHeader } from '@/components/ui/section';
8 +import { Unavailable } from '@/components/ui/unavailable';
9 +import { api, safe } from '@/lib/api';
10 +import { fmtAgo, fmtInt } from '@/lib/format';
11 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
12 +
13 +export const revalidate = 60;
14 +
15 +type Search = Promise<{ page?: string | string[]; type?: string | string[]; since?: string | string[] }>;
16 +const PAGE_SIZE = 50;
17 +const WINDOWS = [
18 + { key: '24h', label: 'Last 24 h', hours: 24 },
19 + { key: '7d', label: 'Last 7 d', hours: 24 * 7 },
20 + { key: '30d', label: 'Last 30 d', hours: 24 * 30 },
21 +] as const;
22 +
23 +const pick = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
24 +
25 +const TITLE = 'Orbital events timeline — launches, decays, orbit changes';
26 +const DESC = 'Chronological feed of what changed in Earth orbit: newly catalogued payloads, decays and reentries, orbit changes and decommissions, with source and derived confidence for every event.';
27 +
28 +export async function generateMetadata({ searchParams }: { searchParams: Search }): Promise<Metadata> {
29 + const sp = await searchParams;
30 + const type = pick(sp.type);
31 + const title = type ? `${eventLabel(type)} events — timeline` : TITLE;
32 + const url = `${SITE_URL}${type ? routes.events(`type=${encodeURIComponent(type)}`) : routes.events()}`;
33 + return {
34 + title,
35 + description: DESC,
36 + alternates: { canonical: url },
37 + openGraph: { title: `${title} | ${SITE_NAME}`, description: DESC, url, type: 'website', siteName: SITE_NAME },
38 + twitter: { card: 'summary_large_image', title: `${title} | ${SITE_NAME}`, description: DESC },
39 + };
40 +}
41 +
42 +function buildHref(params: { page?: number; type?: string; since?: string }): string {
43 + const p = new URLSearchParams();
44 + if (params.type) p.set('type', params.type);
45 + if (params.since) p.set('since', params.since);
46 + if (params.page && params.page > 1) p.set('page', String(params.page));
47 + const s = p.toString();
48 + return routes.events(s || undefined);
49 +}
50 +
51 +export default async function EventsPage({ searchParams }: { searchParams: Search }) {
52 + const sp = await searchParams;
53 + const page = Math.max(1, Number(pick(sp.page) ?? 1) || 1);
54 + const type = pick(sp.type) || undefined;
55 + const sinceRaw = pick(sp.since) || undefined;
56 + const since = sinceRaw && !Number.isNaN(new Date(sinceRaw).getTime()) ? sinceRaw : undefined;
57 +
58 + const base = { page, page_size: PAGE_SIZE, type };
59 + let payload = await safe(api.events({ ...base, since }));
60 + let sinceFailed = false;
61 + if (!payload && since) {
62 + // The API currently rejects `since` (500) — degrade honestly to the unfiltered feed and say so.
63 + sinceFailed = true;
64 + payload = await safe(api.events(base));
65 + }
66 +
67 + const types = payload?.types ?? [];
68 + const now = Date.now();
69 + const typeChips = [{ href: buildHref({ since }), label: 'All types', active: !type, count: types.length ? fmtInt(types.reduce((s, t) => s + (Number(t.count) || 0), 0)) : undefined }, ...types.map((t) => ({ href: buildHref({ type: t.type, since }), label: eventLabel(t.type), active: type === t.type, count: fmtInt(t.count) }))];
70 + const windowChips = [{ href: buildHref({ type }), label: 'All time', active: !since }, ...WINDOWS.map((w) => {
71 + const iso = new Date(now - w.hours * 3600_000).toISOString().slice(0, 19) + 'Z';
72 + // active if current `since` is within ±1 h of this window
73 + const active = !!since && Math.abs(new Date(since).getTime() - (now - w.hours * 3600_000)) < 3600_000;
74 + return { href: buildHref({ type, since: iso }), label: w.label, active };
75 + })];
76 +
77 + return (
78 + <Container>
79 + <PageHeader eyebrow="Events" title="Orbital events timeline" lede="Every change detected by SatelliteIndex connectors, newest first: payloads catalogued from a launch, decays, orbit changes, decommissions. Each event carries its source and a derived confidence.">
80 + {payload && payload.data[0] && <p className="mono mt-4 text-xs text-ink-3">Latest event {fmtAgo(payload.data[0].event_time)} · {fmtInt(payload.pagination.total)} events in view</p>}
81 + </PageHeader>
82 +
83 + <div className="space-y-3 pb-6">
84 + <Chips items={typeChips} ariaLabel="Event type" />
85 + <Chips items={windowChips} ariaLabel="Time window" />
86 + </div>
87 +
88 + {sinceFailed && (
89 + <Note tone="warn" className="mb-6">
90 + The time-window filter is unavailable from the API right now — showing the unfiltered feed instead. <Link href={buildHref({ type })} className="text-accent hover:underline">Clear window</Link>
91 + </Note>
92 + )}
93 +
94 + {!payload ? (
95 + <div className="py-6">
96 + <Unavailable what="Events feed" />
97 + </div>
98 + ) : payload.data.length === 0 ? (
99 + <div className="py-6">
100 + <Unavailable what="Events matching these filters" />
101 + <Note className="mt-3">
102 + Only event types produced by connected sources appear here. <Link href={routes.events()} className="text-accent hover:underline">Reset filters</Link>
103 + </Note>
104 + </div>
105 + ) : (
106 + <>
107 + <EventTimeline events={payload.data} />
108 + <Pagination className="mt-8" page={payload.pagination.page} pages={payload.pagination.pages} total={payload.pagination.total} pageSize={payload.pagination.page_size} makeHref={(p) => buildHref({ page: p, type, since: sinceFailed ? undefined : since })} />
109 + </>
110 + )}
111 +
112 + <Note className="mt-10 pb-8">
113 + Times are UTC. Confidence is a <Link href={routes.methodology()} className="text-accent hover:underline">derived</Link> score, not a measurement. Event types that no connected source produces (e.g. regulatory approvals) are simply absent — nothing is simulated.
114 + </Note>
115 + </Container>
116 + );
117 +}
added apps/web/src/app/explore/page.tsx +42 −0
@@ -0,0 +1,42 @@
1 +import type { Metadata } from 'next';
2 +import { LazyGlobe } from '@/components/globe/lazy-globe';
3 +import { api, safe } from '@/lib/api';
4 +import { fmtInt, num } from '@/lib/format';
5 +import { SITE_URL } from '@/lib/site';
6 +
7 +export const revalidate = 300;
8 +
9 +/** "16,000+" from the live count of objects with element sets — never a hardcoded figure. */
10 +async function trackedCount(): Promise<string | null> {
11 + const home = await safe(api.home());
12 + const n = num(home?.data.stats.with_elements);
13 + if (!n || n < 1000) return null;
14 + return `${fmtInt(Math.floor(n / 1000) * 1000)}+`;
15 +}
16 +
17 +export async function generateMetadata(): Promise<Metadata> {
18 + const count = await trackedCount();
19 + const title = count ? `Explore orbit — live 3D globe of ${count} tracked satellites` : 'Explore orbit — live 3D globe of tracked satellites';
20 + const description = count
21 + ? `Interactive 3D globe of ${count} satellites and objects with current element sets, propagated with SGP4 every 30 seconds. Filter by orbit class and mission type, search any satellite and follow it live.`
22 + : 'Interactive 3D globe of every satellite with a current element set, propagated with SGP4 every 30 seconds. Filter by orbit class and mission type, search any satellite and follow it live.';
23 + return {
24 + title,
25 + description,
26 + alternates: { canonical: '/explore' },
27 + openGraph: { title, description, url: `${SITE_URL}/explore`, type: 'website' },
28 + twitter: { card: 'summary_large_image', title, description },
29 + };
30 +}
31 +
32 +export default async function ExplorePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
33 + const sp = await searchParams;
34 + const raw = Array.isArray(sp.focus) ? sp.focus[0] : sp.focus;
35 + const focus = raw && /^\d{1,9}$/.test(raw) ? Number(raw) : null;
36 + return (
37 + <div className="relative w-full h-[calc(100dvh-var(--header-h)-var(--tabbar-h)-env(safe-area-inset-bottom,0px))] md:h-[calc(100dvh-var(--header-h))]">
38 + <h1 className="sr-only">Explore orbit — live 3D globe</h1>
39 + <LazyGlobe variant="full" initialFocus={focus} />
40 + </div>
41 + );
42 +}
added apps/web/src/app/launch-sites/[slug]/page.tsx +120 −0
@@ -0,0 +1,120 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Bars, HBars } from '@/components/charts/charts';
5 +import { LaunchesTable } from '@/components/launches/launches-table';
6 +import { SitesMap } from '@/components/launches/sites-map';
7 +import { Block, Empty, Head } from '@/components/satellite/primitives';
8 +import { Container, Stat } from '@/components/ui/section';
9 +import { Unavailable } from '@/components/ui/unavailable';
10 +import { api, ApiError, safe } from '@/lib/api';
11 +import { fmtDate, fmtInt, num } from '@/lib/format';
12 +import { routes, SITE_URL } from '@/lib/site';
13 +
14 +type Params = { params: Promise<{ slug: string }> };
15 +type SiteDetail = Awaited<ReturnType<typeof api.launchSite>>['data'];
16 +
17 +async function load(slug: string): Promise<SiteDetail> {
18 + try {
19 + return (await api.launchSite(slug)).data;
20 + } catch (e) {
21 + if (e instanceof ApiError && e.notFound) notFound();
22 + throw e;
23 + }
24 +}
25 +
26 +/** The detail endpoint has no totals; derive them from the per-year series (real data, no guesses). */
27 +function totals(s: SiteDetail) {
28 + const launches = s.years.reduce((a, y) => a + (num(y.launches) ?? 0), 0);
29 + const payloads = s.years.reduce((a, y) => a + (num(y.payloads) ?? 0), 0);
30 + const first = s.years[0]?.year ?? null;
31 + const last = s.years[s.years.length - 1]?.year ?? null;
32 + const lastLaunch = s.recent_launches.reduce<string | null>((m, l) => (l.launch_date && (!m || l.launch_date > m) ? l.launch_date : m), null);
33 + return { launches, payloads, first, last, lastLaunch };
34 +}
35 +
36 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
37 + const { slug } = await params;
38 + const res = await safe(api.launchSite(slug));
39 + if (!res) return { title: 'Launch site', robots: { index: false } };
40 + const s = res.data;
41 + const t = totals(s);
42 + const title = `${s.name} — Launch site${s.country_name ? `, ${s.country_name}` : ''}`;
43 + const description = `${s.name}${s.country_name ? ` (${s.country_name})` : ''}: ${fmtInt(t.launches)} orbital launches and ${fmtInt(t.payloads)} payloads${t.first ? ` since ${t.first}` : ''}${t.lastLaunch ? `, most recent on ${fmtDate(t.lastLaunch)}` : ''}. Launches per year, recent launches and top owners on SatelliteIndex.`;
44 + const canonical = routes.launchSite(s.slug);
45 + return { title, description, alternates: { canonical }, openGraph: { title, description, url: `${SITE_URL}${canonical}` }, twitter: { card: 'summary', title, description } };
46 +}
47 +
48 +export default async function LaunchSitePage({ params }: Params) {
49 + const { slug } = await params;
50 + const s = await load(slug);
51 + const t = totals(s);
52 + const hasCoords = s.latitude !== null && s.longitude !== null;
53 + const yearsData = s.years.map((y) => ({ x: String(y.year), y: num(y.launches) ?? 0 }));
54 + const owners = [...s.owners].sort((a, b) => (num(b.launches) ?? 0) - (num(a.launches) ?? 0)).slice(0, 12);
55 + const jsonLd = { '@context': 'https://schema.org', '@type': 'Place', name: s.name, url: `${SITE_URL}${routes.launchSite(s.slug)}`, identifier: s.code, ...(hasCoords ? { geo: { '@type': 'GeoCoordinates', latitude: s.latitude, longitude: s.longitude } } : {}), ...(s.country_name ? { address: { '@type': 'PostalAddress', addressCountry: s.country_code ?? s.country_name } } : {}) };
56 +
57 + return (
58 + <Container wide>
59 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
60 + <header className="pb-6 pt-6 md:pt-10">
61 + <p className="eyebrow mono">
62 + Launch site · {s.code}
63 + {hasCoords && <> · {s.latitude!.toFixed(2)}°, {s.longitude!.toFixed(2)}°</>}
64 + </p>
65 + <h1 className="display mt-2 break-words text-3xl md:text-5xl">{s.name}</h1>
66 + <p className="mt-3 text-sm text-ink-2">
67 + {s.country_slug ? <Link href={routes.country(s.country_slug)} className="link">{s.country_name}</Link> : s.country_name ?? 'Country unknown'}
68 + {' · '}
69 + <Link href={routes.launches(`site=${encodeURIComponent(s.slug)}`)} className="link">All launches from this site</Link>
70 + </p>
71 + </header>
72 +
73 + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">
74 + <div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-4 lg:grid-cols-2">
75 + <Stat label="Orbital launches" value={fmtInt(t.launches)} hint={t.first ? `since ${t.first}` : undefined} />
76 + <Stat label="Payloads" value={fmtInt(t.payloads)} />
77 + <Stat label="Active years" value={fmtInt(s.years.length)} hint={t.first && t.last ? `${t.first} – ${t.last}` : undefined} />
78 + <Stat label="Most recent launch" value={<span className="text-xl md:text-2xl">{fmtDate(t.lastLaunch)}</span>} hint={t.lastLaunch ? undefined : 'no dated launch in the recent list'} />
79 + </div>
80 + <div>
81 + {hasCoords ? <SitesMap sites={[{ code: s.code, name: s.name, slug: s.slug, country_code: s.country_code, latitude: s.latitude, longitude: s.longitude, launches: t.launches, launches_last_365d: 0, last_launch: t.lastLaunch }]} highlight={s.slug} labelTop={1} /> : <Unavailable what="Site coordinates" compact />}
82 + </div>
83 + </div>
84 +
85 + <div className="mt-6 divide-y divide-[color:var(--rule)]">
86 + <Block id="years">
87 + <Head eyebrow="Cadence" title="Launches per year" />
88 + {yearsData.length ? <Bars data={yearsData} title={`Launches per year from ${s.name}`} height={200} xTicks={10} highlightLast /> : <Empty>No dated launches on file.</Empty>}
89 + </Block>
90 +
91 + <Block id="recent">
92 + <Head eyebrow="Recent" title={<>Recent launches <span className="tnum text-ink-3">· {fmtInt(s.recent_launches.length)}</span></>} action={{ href: routes.launches(`site=${encodeURIComponent(s.slug)}`), label: 'Full list' }} />
93 + {s.recent_launches.length ? <LaunchesTable rows={s.recent_launches} showSite={false} ownerHref={(c) => routes.launches(`site=${encodeURIComponent(s.slug)}&owner=${encodeURIComponent(c)}`)} /> : <Empty>No launches recorded for this site.</Empty>}
94 + </Block>
95 +
96 + <Block id="owners">
97 + <Head eyebrow="Customers" title="Top owners launched from here" />
98 + {owners.length ? (
99 + <HBars data={owners.map((o) => ({ label: `${o.name} (${o.code})`, value: num(o.launches) ?? 0 }))} max={num(owners[0]?.launches) ?? undefined} />
100 + ) : (
101 + <Empty>No owner information for the objects launched from this site.</Empty>
102 + )}
103 + {owners.length > 0 && (
104 + <ul className="mt-3 flex flex-wrap gap-1.5">
105 + {owners.map((o) => (
106 + <li key={o.code}>
107 + <Link href={routes.launches(`site=${encodeURIComponent(s.slug)}&owner=${encodeURIComponent(o.code)}`)} className="mono inline-flex min-h-9 items-center rounded-md border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">{o.code}</Link>
108 + </li>
109 + ))}
110 + </ul>
111 + )}
112 + </Block>
113 + </div>
114 +
115 + <p className="pb-10 pt-4 text-2xs text-ink-3">
116 + Totals are summed from the per-year series of launches attributed to site code {s.code} in SATCAT; launches are derived from international designators (<Link href={routes.methodology()} className="hover:text-accent">methodology</Link>).
117 + </p>
118 + </Container>
119 + );
120 +}
added apps/web/src/app/launch-sites/page.tsx +85 −0
@@ -0,0 +1,85 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { SitesMap } from '@/components/launches/sites-map';
4 +import { Container, PageHeader } from '@/components/ui/section';
5 +import { Unavailable } from '@/components/ui/unavailable';
6 +import { api, safe } from '@/lib/api';
7 +import { fmtDate, fmtInt, num } from '@/lib/format';
8 +import { routes, SITE_URL } from '@/lib/site';
9 +
10 +export const metadata: Metadata = {
11 + title: 'Launch sites — every spaceport that put an object in orbit',
12 + description: 'World map and table of launch sites: total launches, activity over the last 365 days, payloads and most recent launch, derived from SATCAT launch-site codes.',
13 + alternates: { canonical: routes.launchSites() },
14 + openGraph: { title: 'Launch sites of the world', description: 'Spaceports ranked by orbital launches, with recent activity and last launch.', url: `${SITE_URL}${routes.launchSites()}` },
15 + twitter: { card: 'summary_large_image', title: 'Launch sites of the world', description: 'Spaceports ranked by orbital launches, with recent activity and last launch.' },
16 +};
17 +
18 +export default async function LaunchSitesPage() {
19 + const res = await safe(api.launchSites());
20 + const sites = res ? [...res.data].sort((a, b) => (num(b.launches) ?? 0) - (num(a.launches) ?? 0)) : null;
21 + const active365 = sites?.filter((s) => (num(s.launches_last_365d) ?? 0) > 0).length ?? null;
22 +
23 + return (
24 + <Container wide>
25 + <PageHeader
26 + eyebrow={<>Infrastructure · {sites ? <span className="tnum">{fmtInt(sites.length)} sites · {fmtInt(active365)} active in the last 365 days</span> : 'count unavailable'}</>}
27 + title="Launch sites"
28 + lede="Every launch site referenced by a catalogued object, from Baikonur in 1957 to today's commercial pads. Marker area is proportional to the number of orbital launches attributed to the site."
29 + />
30 +
31 + {sites === null ? (
32 + <Unavailable what="Launch sites" />
33 + ) : (
34 + <>
35 + <section className="pb-8" aria-label="Map of launch sites">
36 + <SitesMap sites={sites} labelTop={5} />
37 + </section>
38 +
39 + <section className="pb-10" aria-label="Launch sites table">
40 + <div className="overflow-x-auto scrollbar-thin">
41 + <table className="data-table stack">
42 + <thead>
43 + <tr>
44 + <th>Site</th>
45 + <th>Code</th>
46 + <th>Country</th>
47 + <th className="num">Launches</th>
48 + <th className="num">Last 365 d</th>
49 + <th className="num">Payloads</th>
50 + <th>First launch</th>
51 + <th>Last launch</th>
52 + </tr>
53 + </thead>
54 + <tbody>
55 + {sites.map((s) => {
56 + const recent = num(s.launches_last_365d) ?? 0;
57 + return (
58 + <tr key={s.code}>
59 + <td data-label="Site" className="primary">
60 + <Link href={routes.launchSite(s.slug)} className="link font-medium">{s.name}</Link>
61 + {s.latitude === null && <span className="ml-2 text-2xs text-ink-3">no coordinates</span>}
62 + </td>
63 + <td data-label="Code" className="mono text-xs text-ink-2">{s.code}</td>
64 + <td data-label="Country" className="text-xs">{s.country_code ? <Link href={routes.country(s.country_code)} className="link">{s.country_name ?? s.country_code}</Link> : '—'}</td>
65 + <td data-label="Launches" className="num mono text-xs">{fmtInt(s.launches)}</td>
66 + <td data-label="Last 365 d" className={`num mono text-xs ${recent ? 'text-active' : 'text-ink-3'}`}>{fmtInt(s.launches_last_365d)}</td>
67 + <td data-label="Payloads" className="num mono text-xs">{fmtInt(s.payloads)}</td>
68 + <td data-label="First launch" className="mono text-xs">{fmtDate(s.first_launch)}</td>
69 + <td data-label="Last launch" className="mono text-xs">{fmtDate(s.last_launch)}</td>
70 + </tr>
71 + );
72 + })}
73 + </tbody>
74 + </table>
75 + </div>
76 + </section>
77 + </>
78 + )}
79 +
80 + <p className="pb-10 text-2xs text-ink-3">
81 + Sites and coordinates come from the SATCAT launch-site code table; launches are derived from international designators (see <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>). Sea launches and air launches are attributed to their reference site.
82 + </p>
83 + </Container>
84 + );
85 +}
added apps/web/src/app/launch/[cospar]/page.tsx +192 −0
@@ -0,0 +1,192 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { WorldMap } from '@/components/map/world-map';
5 +import { Block, DL, Empty, Head, Row } from '@/components/satellite/primitives';
6 +import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';
7 +import { Container, Stat } from '@/components/ui/section';
8 +import { api, ApiError } from '@/lib/api';
9 +import { fmtDate, fmtDateTime, fmtDeg, fmtInt, num, titleCase } from '@/lib/format';
10 +import { EVENT_TYPE_LABELS, OBJECT_TYPE_LABELS, routes, SITE_URL } from '@/lib/site';
11 +import type { LaunchDetail } from '@/lib/types';
12 +
13 +type Params = { params: Promise<{ cospar: string }> };
14 +const TYPE_ORDER = ['PAYLOAD', 'STATION', 'CREWED', 'ROCKET_BODY', 'DEBRIS', 'UNKNOWN'];
15 +
16 +async function load(cospar: string): Promise<LaunchDetail> {
17 + try {
18 + return (await api.launch(cospar)).data;
19 + } catch (e) {
20 + if (e instanceof ApiError && e.notFound) notFound();
21 + throw e;
22 + }
23 +}
24 +
25 +function describe(l: LaunchDetail): string {
26 + return `Launch ${l.cospar_launch_id}${l.primary_name ? ` (${l.primary_name})` : ''} on ${fmtDate(l.launch_date)}${l.site_name ? ` from ${l.site_name}` : ''}: ${fmtInt(l.payload_count)} payloads, ${fmtInt(l.object_count)} catalogued objects, ${fmtInt(l.on_orbit_count)} still on orbit. Every object with its status, orbit and owner on SatelliteIndex.`;
27 +}
28 +
29 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
30 + const { cospar } = await params;
31 + let l: LaunchDetail;
32 + try {
33 + l = (await api.launch(cospar)).data;
34 + } catch {
35 + return { title: 'Launch', robots: { index: false } };
36 + }
37 + const title = `${l.primary_name ?? 'Launch'} — Launch ${l.cospar_launch_id}, ${fmtDate(l.launch_date)}`;
38 + const description = describe(l);
39 + const canonical = routes.launch(l.cospar_launch_id);
40 + return { title, description, alternates: { canonical }, openGraph: { title, description, url: `${SITE_URL}${canonical}`, type: 'article' }, twitter: { card: 'summary', title, description } };
41 +}
42 +
43 +function OwnerChips({ owners }: { owners: LaunchDetail['owners'] }) {
44 + return (
45 + <ul className="flex flex-wrap gap-1.5">
46 + {owners.map((o) => (
47 + <li key={o.code}>
48 + <Link href={routes.launches(`owner=${encodeURIComponent(o.code)}`)} className="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" title={`${o.kind} · all launches with owner ${o.code}`}>
49 + <span className="mono text-ink">{o.code}</span> {o.name}
50 + </Link>
51 + </li>
52 + ))}
53 + </ul>
54 + );
55 +}
56 +
57 +export default async function LaunchPage({ params }: Params) {
58 + const { cospar } = await params;
59 + const l = await load(cospar);
60 + const objects = num(l.object_count) ?? l.objects.length;
61 + const onOrbit = num(l.on_orbit_count) ?? 0;
62 + const groups = TYPE_ORDER.map((t) => ({ type: t, rows: l.objects.filter((o) => o.object_type === t) })).filter((g) => g.rows.length);
63 + const hasSite = l.site_lat != null && l.site_lon != null;
64 + const jsonLd = { '@context': 'https://schema.org', '@type': 'Event', name: `Launch ${l.cospar_launch_id}${l.primary_name ? ` — ${l.primary_name}` : ''}`, startDate: l.launch_date, url: `${SITE_URL}${routes.launch(l.cospar_launch_id)}`, description: describe(l), location: l.site_name ? { '@type': 'Place', name: l.site_name, ...(hasSite ? { geo: { '@type': 'GeoCoordinates', latitude: l.site_lat, longitude: l.site_lon } } : {}) } : undefined, identifier: { '@type': 'PropertyValue', propertyID: 'COSPAR launch id', value: l.cospar_launch_id } };
65 +
66 + return (
67 + <Container wide>
68 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
69 + <header className="pb-6 pt-6 md:pt-10">
70 + <p className="eyebrow mono">Launch · COSPAR {l.cospar_launch_id} · {fmtDate(l.launch_date)}</p>
71 + <h1 className="display mt-2 break-words text-3xl md:text-5xl">{l.primary_name ?? `Launch ${l.cospar_launch_id}`}</h1>
72 + <p className="mt-3 text-sm text-ink-2">
73 + {l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? 'Launch site unknown'}
74 + {l.site_country && <> · <Link href={routes.country(l.site_country)} className="link mono">{l.site_country}</Link></>}
75 + {hasSite && <span className="mono ml-2 text-xs text-ink-3">{l.site_lat!.toFixed(2)}°, {l.site_lon!.toFixed(2)}°</span>}
76 + </p>
77 + </header>
78 +
79 + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">
80 + <div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-4">
81 + <Stat label="Payloads" value={fmtInt(l.payload_count)} />
82 + <Stat label="Catalogued objects" value={fmtInt(objects)} hint="payloads + rocket bodies + debris" />
83 + <Stat label="Still on orbit" value={fmtInt(onOrbit)} accent={onOrbit > 0} />
84 + <Stat label="Decayed" value={fmtInt(objects - onOrbit)} hint={objects ? `${((1 - onOrbit / objects) * 100).toFixed(0)}% of objects` : undefined} />
85 + <div className="col-span-2 sm:col-span-4">
86 + <p className="eyebrow mb-2">Owners (SATCAT owner codes)</p>
87 + {l.owners.length ? (
88 + <>
89 + <OwnerChips owners={l.owners.slice(0, 12)} />
90 + {l.owners.length > 12 && (
91 + <details className="mt-2">
92 + <summary className="inline-flex min-h-9 cursor-pointer items-center text-xs text-accent hover:underline">Show {fmtInt(l.owners.length - 12)} more owners</summary>
93 + <div className="mt-2"><OwnerChips owners={l.owners.slice(12)} /></div>
94 + </details>
95 + )}
96 + </>
97 + ) : (
98 + <Empty>No owner codes recorded for the objects of this launch.</Empty>
99 + )}
100 + </div>
101 + </div>
102 + <div className="overflow-hidden rounded-lg border border-rule">
103 + {hasSite ? (
104 + <WorldMap markers={[{ lat: l.site_lat!, lon: l.site_lon!, color: 'var(--accent)', size: 6, pulse: true, label: l.site_name ?? undefined, href: l.site_slug ? routes.launchSite(l.site_slug) : undefined }]} title={`Launch site: ${l.site_name ?? 'unknown'}`} />
105 + ) : (
106 + <p className="p-6 text-center text-sm text-ink-3">Launch site coordinates unavailable</p>
107 + )}
108 + </div>
109 + </div>
110 +
111 + <div className="mt-6 divide-y divide-[color:var(--rule)]">
112 + <Block id="objects">
113 + <Head eyebrow="Catalogue" title={<>Objects from this launch <span className="tnum text-ink-3">· {fmtInt(l.objects.length)}{objects > l.objects.length ? ` of ${fmtInt(objects)}` : ''}</span></>} action={{ href: routes.satellites(`launch=${encodeURIComponent(l.cospar_launch_id)}`), label: 'Open in explorer' }} />
114 + {groups.length === 0 && <Empty>No catalogued object is linked to this launch.</Empty>}
115 + {groups.map((g) => {
116 + const collapsed = g.rows.length > 40;
117 + const table = (
118 + <div className="overflow-x-auto scrollbar-thin">
119 + <table className="data-table stack">
120 + <thead>
121 + <tr><th>Name</th><th>NORAD</th><th>COSPAR</th><th>Status</th><th>Orbit</th><th className="num">Perigee / apogee</th><th className="num">Incl.</th><th>Decayed</th><th>Operator</th></tr>
122 + </thead>
123 + <tbody>
124 + {g.rows.map((o) => (
125 + <tr key={o.id}>
126 + <td data-label="Name" className="primary"><Link href={routes.satellite(o.slug)} className="link font-medium">{o.name}</Link>{o.constellation_slug && <Link href={routes.constellation(o.constellation_slug)} className="ml-2 text-2xs text-ink-3 hover:text-accent">{o.constellation_name}</Link>}</td>
127 + <td data-label="NORAD" className="mono text-xs">{o.norad_id ?? '—'}</td>
128 + <td data-label="COSPAR" className="mono text-xs">{o.cospar_id ?? '—'}</td>
129 + <td data-label="Status"><StatusBadge status={o.status} /></td>
130 + <td data-label="Orbit"><OrbitBadge orbitClass={o.orbit_class} /></td>
131 + <td data-label="Perigee / apogee" className="num mono text-xs">{o.perigee_km !== null ? `${fmtInt(o.perigee_km)} / ${fmtInt(o.apogee_km)} km` : '—'}</td>
132 + <td data-label="Inclination" className="num mono text-xs">{fmtDeg(o.inclination_deg)}</td>
133 + <td data-label="Decayed" className="mono text-xs">{o.decay_date ? fmtDate(o.decay_date) : <span className="text-ink-3">on orbit</span>}</td>
134 + <td data-label="Operator" className="text-xs">{o.operator_slug ? <Link href={routes.operator(o.operator_slug)} className="link">{o.operator_name}</Link> : o.operator_name ?? '—'}</td>
135 + </tr>
136 + ))}
137 + </tbody>
138 + </table>
139 + </div>
140 + );
141 + const heading = <span className="inline-flex items-center gap-2 text-sm font-semibold text-ink-2"><TypeBadge type={g.type} /> {OBJECT_TYPE_LABELS[g.type] ?? g.type} <span className="tnum text-ink-3">· {fmtInt(g.rows.length)}</span></span>;
142 + return collapsed ? (
143 + <details key={g.type} className="mt-6 first:mt-0">
144 + <summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-md border border-rule px-3 hover:bg-plane-2">
145 + {heading}
146 + <span className="text-xs text-ink-3">show all</span>
147 + </summary>
148 + <div className="mt-2">{table}</div>
149 + </details>
150 + ) : (
151 + <div key={g.type} className="mt-6 first:mt-0">
152 + <p className="mb-2">{heading}</p>
153 + {table}
154 + </div>
155 + );
156 + })}
157 + </Block>
158 +
159 + <Block id="details">
160 + <Head eyebrow="Record" title="Launch record" />
161 + <DL>
162 + <Row label="COSPAR launch id" value={l.cospar_launch_id} />
163 + <Row label="Launch date" value={fmtDate(l.launch_date)} hint="(SATCAT, UTC)" />
164 + <Row label="Launch site" mono={false} value={l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? '—'} />
165 + <Row label="Site code" value={l.site_code ?? '—'} />
166 + <Row label="SatelliteIndex id" value={<span className="break-all text-xs">{l.id}</span>} />
167 + </DL>
168 + </Block>
169 +
170 + <Block id="events">
171 + <Head eyebrow="Timeline" title="Events" />
172 + {l.events.length ? (
173 + <ol className="divide-y divide-[color:var(--rule)]">
174 + {l.events.map((e) => (
175 + <li key={e.id} className="grid gap-1 py-3 sm:grid-cols-[150px_minmax(0,1fr)]">
176 + <p className="mono text-xs text-ink-3">{fmtDateTime(e.event_time)}</p>
177 + <div><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><span className="text-sm">{e.title}</span>{e.summary && <p className="mt-1 text-xs text-ink-2">{e.summary}</p>}</div>
178 + </li>
179 + ))}
180 + </ol>
181 + ) : (
182 + <Empty>No events recorded for this launch or its objects.</Empty>
183 + )}
184 + </Block>
185 + </div>
186 +
187 + <p className="pb-10 pt-4 text-2xs text-ink-3">
188 + This launch is derived from catalogued objects sharing the international designator {l.cospar_launch_id}; date and site come from SATCAT. See the <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>.
189 + </p>
190 + </Container>
191 + );
192 +}
added apps/web/src/app/launches/loading.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import { Bone, TableSkeleton } from '@/components/satellite/skeleton';
2 +import { Container } from '@/components/ui/section';
3 +
4 +export default function Loading() {
5 + return (
6 + <Container wide>
7 + <div className="pb-6 pt-8 md:pb-8 md:pt-12" role="status" aria-label="Loading launches">
8 + <Bone className="h-3 w-40" />
9 + <Bone className="mt-3 h-10 w-1/2 max-w-md md:h-14" />
10 + </div>
11 + <div className="grid gap-8 pb-8 lg:grid-cols-[3fr_2fr]">
12 + <Bone className="h-56 w-full" />
13 + <Bone className="h-56 w-full" />
14 + </div>
15 + <Bone className="h-36 w-full rounded-lg" />
16 + <div className="py-6">
17 + <TableSkeleton rows={14} />
18 + </div>
19 + </Container>
20 + );
21 +}
added apps/web/src/app/launches/page.tsx +84 −0
@@ -0,0 +1,84 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { LaunchFilterForm, launchHref, launchTitle, parseLaunchQuery } from '@/components/launches/launch-filters';
4 +import { LaunchTimelineCharts } from '@/components/launches/launch-timeline';
5 +import { LaunchesTable } from '@/components/launches/launches-table';
6 +import { Pagination } from '@/components/ui/pagination';
7 +import { Container, PageHeader } from '@/components/ui/section';
8 +import { Unavailable } from '@/components/ui/unavailable';
9 +import { api, safe } from '@/lib/api';
10 +import { fmtInt } from '@/lib/format';
11 +import { routes, SITE_URL } from '@/lib/site';
12 +
13 +const PAGE_SIZE = 50;
14 +type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
15 +
16 +export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
17 + const q = parseLaunchQuery(await searchParams);
18 + const filtered = Object.keys(q).some((k) => k !== 'page' && k !== 'sort');
19 + // Same cached request as the page body → real site names in the title instead of slugs.
20 + const sites = q.site ? ((await safe(api.launchTimeline()))?.data.sites ?? null) : null;
21 + const title = filtered ? `${launchTitle(q, sites)} — Launches` : 'Launches — every orbital launch since 1957, derived from the catalogue';
22 + const description = 'Orbital launches reconstructed from SATCAT international designators: date, site, payload count, catalogued objects still on orbit, owners. Filter by year, site, country and owner.';
23 + const canonical = launchHref(q, {}, false);
24 + return { title, description, alternates: { canonical }, robots: q.page || q.q ? { index: false, follow: true } : undefined, openGraph: { title, description, url: `${SITE_URL}${canonical}` }, twitter: { card: 'summary_large_image', title, description } };
25 +}
26 +
27 +export default async function LaunchesPage({ searchParams }: Props) {
28 + const q = parseLaunchQuery(await searchParams);
29 + const page = Math.max(1, Number(q.page ?? '1') || 1);
30 + const [list, timelineRes] = await Promise.all([safe(api.launches({ ...q, page, page_size: PAGE_SIZE, sort: q.sort ?? 'date' })), safe(api.launchTimeline())]);
31 + const timeline = timelineRes?.data ?? null;
32 + const filtered = Object.keys(q).some((k) => k !== 'page' && k !== 'sort');
33 + const total = list?.pagination.total ?? null;
34 +
35 + return (
36 + <Container wide>
37 + <PageHeader
38 + eyebrow={<>Launch record · {total !== null ? <span className="tnum">{fmtInt(total)} launches{filtered ? ' match' : ''}</span> : 'count unavailable'}</>}
39 + title={filtered ? launchTitle(q, timeline?.sites ?? null) : 'Orbital launches'}
40 + lede={filtered ? undefined : 'Every launch that left at least one catalogued object in orbit, grouped by international designator. Counts of payloads, rocket bodies and debris are taken from the live catalogue, so "on orbit" changes as objects decay.'}
41 + />
42 +
43 + <section className="pb-8" aria-label="Launch timeline">
44 + {timeline ? <LaunchTimelineCharts timeline={timeline} /> : <Unavailable what="Launch timeline" />}
45 + </section>
46 +
47 + <div className="space-y-4 pb-4">
48 + <LaunchFilterForm q={q} timeline={timeline} />
49 + {filtered && (
50 + <ul className="flex flex-wrap gap-1.5">
51 + {(Object.keys(q) as (keyof typeof q)[]).filter((k) => k !== 'page' && k !== 'sort').map((k) => (
52 + <li key={k}>
53 + <Link href={launchHref(q, { [k]: undefined })} className="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-accent/40 bg-accent-soft px-2.5 py-1 text-xs text-accent hover:bg-accent/20" title={`Remove ${k} filter`}>
54 + <span className="text-accent/70">{k.replace(/_/g, ' ')}:</span> {q[k]} <span aria-hidden>×</span>
55 + </Link>
56 + </li>
57 + ))}
58 + </ul>
59 + )}
60 + </div>
61 +
62 + <section className="py-6" aria-label="Launches">
63 + {list === null ? (
64 + <Unavailable what={q.after || q.before ? 'Launch list (the after/before date filter is currently failing upstream)' : 'Launch list'} />
65 + ) : list.data.length === 0 ? (
66 + <div className="rounded-lg border border-dashed border-rule-strong px-5 py-10 text-center">
67 + <p className="text-sm text-ink-2">No launch matches these filters.</p>
68 + <Link href={routes.launches()} className="mt-4 inline-flex h-10 items-center rounded-md border border-rule px-4 text-sm text-ink hover:bg-plane-2">Clear filters</Link>
69 + </div>
70 + ) : (
71 + <>
72 + <Pagination page={list.pagination.page} pages={list.pagination.pages} total={list.pagination.total} pageSize={list.pagination.page_size} makeHref={(p) => launchHref(q, { page: String(p) }, false)} className="mb-3" />
73 + <LaunchesTable rows={list.data} ownerHref={(c) => launchHref(q, { owner: c })} />
74 + <Pagination page={list.pagination.page} pages={list.pagination.pages} total={list.pagination.total} pageSize={list.pagination.page_size} makeHref={(p) => launchHref(q, { page: String(p) }, false)} className="mt-4" />
75 + </>
76 + )}
77 + </section>
78 +
79 + <p className="pb-10 text-2xs leading-relaxed text-ink-3">
80 + Launches are <em>derived</em>: SatelliteIndex groups catalogued objects by the launch part of their COSPAR international designator (e.g. 1998-067) and takes the earliest launch date and the launch site from SATCAT. Launches that placed nothing in the catalogue (failures below orbit, suborbital flights) do not appear. Region split uses the launch-site country. See the <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>.
81 + </p>
82 + </Container>
83 + );
84 +}
modified apps/web/src/app/layout.tsx +1 −1
@@ -17,7 +17,7 @@ export const metadata: Metadata = {
17 17 alternates: { canonical: '/' },
18 18 openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
19 19 twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
20 − icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }], apple: [{ url: '/apple-icon.png', sizes: '180x180' }] },
20 + icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }], apple: [{ url: '/apple-icon', sizes: '180x180', type: 'image/png' }] },
21 21 };
22 22
23 23 export const viewport: Viewport = {
added apps/web/src/app/manifest.ts +21 −0
@@ -0,0 +1,21 @@
1 +import type { MetadataRoute } from 'next';
2 +import { DESCRIPTION, SITE_NAME, TAGLINE } from '@/lib/site';
3 +
4 +export default function manifest(): MetadataRoute.Manifest {
5 + return {
6 + name: `${SITE_NAME} — ${TAGLINE}`,
7 + short_name: SITE_NAME,
8 + description: DESCRIPTION,
9 + start_url: '/',
10 + display: 'standalone',
11 + orientation: 'any',
12 + background_color: '#060912',
13 + theme_color: '#060912',
14 + lang: 'en',
15 + categories: ['education', 'utilities', 'news'],
16 + icons: [
17 + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
18 + { src: '/apple-icon', sizes: '180x180', type: 'image/png', purpose: 'any' },
19 + ],
20 + };
21 +}
added apps/web/src/app/methodology/page.tsx +114 −0
@@ -0,0 +1,114 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ATTRIBUTION, DISCLAIMER, FIELD_PRIORITY } from '@/components/meta/legal';
4 +import { FreshnessSection, LimitationsSection, MissionLaunchSection, OrbitCalcSection, OrbitClassSection, PipelineSection, ResolutionSection, StatusSection } from '@/components/meta/methodology-sections';
5 +import { ConstellationRulesSection, MetricsSection, OwnerCodesSection } from '@/components/meta/methodology-tables';
6 +import { Callout, DocLayout, DocSection, Prose } from '@/components/meta/prose';
7 +import { Container, PageHeader } from '@/components/ui/section';
8 +import { api, safe } from '@/lib/api';
9 +import { fmtDateTime } from '@/lib/format';
10 +import { SITE_URL, routes } from '@/lib/site';
11 +
12 +export const metadata: Metadata = {
13 + title: 'Methodology — how SatelliteIndex builds and derives its data',
14 + description: 'Ingestion pipeline, source priorities, entity resolution, SGP4 orbit calculation, status and orbit-class rules, constellation membership, versioned derived metrics, freshness thresholds and known limitations.',
15 + alternates: { canonical: `${SITE_URL}/methodology` },
16 + openGraph: { title: 'Methodology | SatelliteIndex', description: 'How every value on SatelliteIndex is sourced, resolved, computed and labelled.', url: `${SITE_URL}/methodology` },
17 + twitter: { card: 'summary', title: 'Methodology | SatelliteIndex', description: 'How every value on SatelliteIndex is sourced, resolved, computed and labelled.' },
18 +};
19 +
20 +const TOC = [
21 + { id: 'pipeline', label: 'Data pipeline' },
22 + { id: 'source-priority', label: 'Source priorities' },
23 + { id: 'entity-resolution', label: 'Entity resolution' },
24 + { id: 'orbit-calculation', label: 'Orbit calculation' },
25 + { id: 'status', label: 'Status classification' },
26 + { id: 'orbit-class', label: 'Orbit class' },
27 + { id: 'constellations', label: 'Constellation membership' },
28 + { id: 'mission-type', label: 'Mission type' },
29 + { id: 'launches', label: 'Launches' },
30 + { id: 'owner-codes', label: 'Owner codes' },
31 + { id: 'metrics', label: 'Derived metrics' },
32 + { id: 'freshness', label: 'Freshness' },
33 + { id: 'limitations', label: 'Limitations' },
34 + { id: 'disclaimer', label: 'Disclaimer' },
35 +];
36 +
37 +export default async function MethodologyPage() {
38 + const res = await safe(api.methodology());
39 + const payload = res?.data ?? null;
40 + const metric = (key: string) => payload?.metrics.find((m) => m.key === key)?.methodology ?? null;
41 + return (
42 + <Container>
43 + <PageHeader eyebrow="Methodology" title="How the index is built" lede="Every value on SatelliteIndex is either a sourced fact with provenance or a derived label with a versioned rule. This page documents the rules — from the raw upstream payload to the position drawn on the globe — so you can decide how much to trust each number.">
44 + <p className="mt-4 text-sm text-ink-3">
45 + Sources and licenses: <Link href={routes.sources()} className="link">/sources</Link> · Live freshness: <Link href={routes.statusData()} className="link">/status/data</Link> · Machine-readable: <a href="/api/v1/methodology" className="link mono">/api/v1/methodology</a>
46 + {res && <span className="ml-2">· definitions fetched {fmtDateTime(res.meta.generated_at)}</span>}
47 + </p>
48 + </PageHeader>
49 +
50 + <DocLayout toc={TOC}>
51 + <div>
52 + <PipelineSection />
53 +
54 + <DocSection id="source-priority" eyebrow="02" title="Source priorities">
55 + <Prose>
56 + <p>
57 + Sources carry a numeric priority; when two sources describe the same field the higher-priority value is kept and the other is still recorded in provenance. In practice each field family has one primary source today:
58 + </p>
59 + </Prose>
60 + <div className="mt-4 overflow-x-auto">
61 + <table className="data-table stack md:min-w-[640px]">
62 + <thead>
63 + <tr>
64 + <th>Field</th>
65 + <th>Primary source</th>
66 + <th>Note</th>
67 + </tr>
68 + </thead>
69 + <tbody>
70 + {FIELD_PRIORITY.map((f) => (
71 + <tr key={f.field}>
72 + <td className="primary text-sm text-ink">{f.field}</td>
73 + <td data-label="Primary source" className="text-sm">
74 + <Link href={`${routes.sources()}#${f.sourceId}`} className="link">
75 + {f.source}
76 + </Link>
77 + </td>
78 + <td data-label="Note" className="text-sm text-ink-2">
79 + {f.note}
80 + </td>
81 + </tr>
82 + ))}
83 + </tbody>
84 + </table>
85 + </div>
86 + </DocSection>
87 +
88 + <ResolutionSection />
89 + <OrbitCalcSection />
90 + <StatusSection methodologyText={metric('status')} />
91 + <OrbitClassSection methodologyText={metric('orbit_class')} />
92 + <ConstellationRulesSection rules={payload?.constellation_rules ?? null} />
93 + <MissionLaunchSection />
94 + <OwnerCodesSection owners={payload?.owner_codes ?? null} />
95 + <MetricsSection metrics={payload?.metrics ?? null} />
96 + <FreshnessSection methodologyText={metric('freshness')} />
97 + <LimitationsSection />
98 +
99 + <DocSection id="disclaimer" eyebrow="14" title="Disclaimer">
100 + <Callout tone="warn" className="mt-0">
101 + {DISCLAIMER}
102 + </Callout>
103 + <Prose className="mt-4">
104 + <p>{ATTRIBUTION}</p>
105 + <p className="text-ink-3">
106 + See the <Link href={routes.terms()} className="link">terms of use</Link>. Corrections and questions: <Link href={routes.about()} className="link">about</Link>.
107 + </p>
108 + </Prose>
109 + </DocSection>
110 + </div>
111 + </DocLayout>
112 + </Container>
113 + );
114 +}
added apps/web/src/app/opengraph-image.tsx +63 −0
@@ -0,0 +1,63 @@
1 +import { ImageResponse } from 'next/og';
2 +import { SITE_NAME, TAGLINE } from '@/lib/site';
3 +
4 +export const runtime = 'nodejs';
5 +export const alt = `${SITE_NAME} — ${TAGLINE}`;
6 +export const size = { width: 1200, height: 630 };
7 +export const contentType = 'image/png';
8 +
9 +/** Default Open Graph image: the SatelliteIndex mark (planet + inclined ring + satellite node) on the space plane. */
10 +export default function OpenGraphImage() {
11 + return new ImageResponse(
12 + (
13 + <div
14 + style={{
15 + width: '100%',
16 + height: '100%',
17 + display: 'flex',
18 + flexDirection: 'column',
19 + justifyContent: 'space-between',
20 + padding: '64px 72px',
21 + background: 'linear-gradient(135deg, #0b1020 0%, #060912 60%, #060912 100%)',
22 + color: '#eaf0ff',
23 + fontFamily: 'sans-serif',
24 + position: 'relative',
25 + }}
26 + >
27 + <div style={{ position: 'absolute', right: -160, top: -120, width: 620, height: 620, borderRadius: 999, background: 'radial-gradient(circle at 35% 35%, rgba(56,211,255,0.16), rgba(6,9,18,0) 70%)', display: 'flex' }} />
28 + <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
29 + <svg width="72" height="72" viewBox="0 0 32 32" fill="none">
30 + <circle cx="16" cy="16" r="7.5" fill="#eaf0ff" opacity="0.92" />
31 + <ellipse cx="16" cy="16" rx="14" ry="5.2" stroke="#eaf0ff" strokeWidth="1.6" transform="rotate(-24 16 16)" opacity="0.85" />
32 + <circle cx="27.4" cy="9.6" r="2.3" fill="#38d3ff" />
33 + </svg>
34 + <div style={{ display: 'flex', fontSize: 40, fontWeight: 700, letterSpacing: -1 }}>
35 + <span>Satellite</span>
36 + <span style={{ color: '#38d3ff' }}>Index</span>
37 + </div>
38 + </div>
39 + <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
40 + <div style={{ fontSize: 66, fontWeight: 700, lineHeight: 1.02, letterSpacing: -2.5, maxWidth: 900, display: 'flex' }}>{TAGLINE}.</div>
41 + <div style={{ fontSize: 26, color: '#a3aec8', display: 'flex' }}>Satellites · constellations · operators · launches · debris · live SGP4 positions</div>
42 + </div>
43 + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 22, color: '#6b7694' }}>
44 + <span>www.satelliteindex.io</span>
45 + <div style={{ display: 'flex', gap: 28 }}>
46 + {[
47 + ['LEO', '#38d3ff'],
48 + ['MEO', '#8f7dff'],
49 + ['GEO', '#f5b544'],
50 + ['HEO', '#ff7ab6'],
51 + ].map(([l, c]) => (
52 + <div key={l} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
53 + <div style={{ width: 12, height: 12, borderRadius: 999, background: c, display: 'flex' }} />
54 + <span>{l}</span>
55 + </div>
56 + ))}
57 + </div>
58 + </div>
59 + </div>
60 + ),
61 + { ...size },
62 + );
63 +}
added apps/web/src/app/operator/[slug]/page.tsx +149 −0
@@ -0,0 +1,149 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Donut, HBars, StackedBars } from '@/components/charts/charts';
5 +import { Block, EventsList, ExternalLink, HeroFacts, KpiStrip, PlannedUnavailable, Tag, entityMetadata } from '@/components/entities/shared';
6 +import { ConstellationsMiniTable, LaunchesTable, SatellitesTable, SitesTable } from '@/components/entities/tables';
7 +import { Container } from '@/components/ui/section';
8 +import { Unavailable } from '@/components/ui/unavailable';
9 +import { ApiError, api } from '@/lib/api';
10 +import { fmtDate, fmtDateTime, fmtInt, num, titleCase } from '@/lib/format';
11 +import { MISSION_LABELS, ORBIT_CLASS_COLORS, SITE_URL, STATUS_COLORS, routes } from '@/lib/site';
12 +import type { OperatorDetail } from '@/lib/types';
13 +
14 +type Props = { params: Promise<{ slug: string }> };
15 +
16 +async function load(slug: string): Promise<{ d: OperatorDetail; generatedAt: string } | null> {
17 + try {
18 + const res = await api.operator(slug);
19 + return { d: res.data, generatedAt: res.meta.generated_at };
20 + } catch (e) {
21 + if (e instanceof ApiError && e.notFound) return null;
22 + throw e;
23 + }
24 +}
25 +
26 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
27 + const { slug } = await params;
28 + const r = await load(slug).catch(() => null);
29 + if (!r) return { title: 'Operator not found', robots: { index: false } };
30 + const { d } = r;
31 + return entityMetadata({
32 + title: `${d.name} — ${fmtInt(d.active_payloads)} active satellites, ${titleCase(d.kind).toLowerCase()}${d.country_name ? ` (${d.country_name})` : ''}`,
33 + description: `${d.name} operates ${fmtInt(d.active_payloads)} active payloads out of ${fmtInt(d.total_payloads)} launched across ${fmtInt(d.launches)} launches since ${fmtDate(d.first_launch)}. Fleet by status, orbit and mission, constellations, launch history and growth.`,
34 + path: routes.operator(d.slug),
35 + });
36 +}
37 +
38 +export default async function OperatorPage({ params }: Props) {
39 + const { slug } = await params;
40 + const r = await load(slug);
41 + if (!r) notFound();
42 + const { d, generatedAt } = r;
43 +
44 + const statusData = d.status_distribution.map((s) => ({ label: titleCase(s.status.toLowerCase()), value: num(s.count) ?? 0, color: STATUS_COLORS[s.status] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);
45 + const orbitData = d.orbit_distribution.map((o) => ({ label: o.orbit_class, value: num(o.count) ?? 0, color: ORBIT_CLASS_COLORS[o.orbit_class] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);
46 + const missionData = d.mission_distribution.map((m) => ({ label: MISSION_LABELS[m.mission_type] ?? titleCase(m.mission_type), value: num(m.count) ?? 0 })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);
47 + const growth = d.growth.map((g) => {
48 + const launched = num(g.launched) ?? 0;
49 + const still = Math.min(launched, num(g.still_active) ?? 0);
50 + return { x: g.year, still_active: still, retired: Math.max(0, launched - still) };
51 + });
52 +
53 + const jsonLd = {
54 + '@context': 'https://schema.org',
55 + '@type': 'Organization',
56 + name: d.name,
57 + alternateName: d.aliases,
58 + url: d.official_url ?? undefined,
59 + sameAs: d.official_url ? [d.official_url] : undefined,
60 + address: d.country_name ? { '@type': 'PostalAddress', addressCountry: d.country_code ?? d.country_name } : undefined,
61 + mainEntityOfPage: `${SITE_URL}${routes.operator(d.slug)}`,
62 + };
63 +
64 + return (
65 + <Container wide>
66 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
67 +
68 + <header className="pb-6 pt-8 md:pb-8 md:pt-12">
69 + <nav aria-label="Breadcrumb" className="eyebrow">
70 + <Link href={routes.operators()} className="hover:text-ink">Operators</Link> <span aria-hidden>/</span> {d.name}
71 + </nav>
72 + <div className="mt-3 flex flex-wrap items-center gap-2">
73 + <Tag tone="accent">{titleCase(d.kind)}</Tag>
74 + {d.country_code && <Tag>{d.country_code}</Tag>}
75 + </div>
76 + <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>
77 + <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">
78 + {d.country_slug ? <Link href={routes.country(d.country_slug)} className="link">{d.country_name}</Link> : 'Country unavailable'}
79 + {' '}· {fmtInt(d.active_payloads)} active payloads · {fmtInt(d.constellations)} {num(d.constellations) === 1 ? 'constellation' : 'constellations'}
80 + </p>
81 + {d.description && <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}
82 + <HeroFacts
83 + items={[
84 + { label: 'Official site', value: d.official_url ? <ExternalLink href={d.official_url} /> : null },
85 + { label: 'Also known as', value: d.aliases.length ? d.aliases.join(' · ') : null },
86 + ]}
87 + />
88 + </header>
89 +
90 + <KpiStrip
91 + items={[
92 + { label: 'Active payloads', value: <span className="text-active">{fmtInt(d.active_payloads)}</span> },
93 + { label: 'On orbit', value: fmtInt(d.on_orbit_payloads) },
94 + { label: 'Total payloads', value: fmtInt(d.total_payloads) },
95 + { label: 'Decayed', value: fmtInt(d.decayed) },
96 + { label: 'Launches', value: fmtInt(d.launches) },
97 + { label: 'Launched 365 d', value: fmtInt(d.payloads_last_365d) },
98 + { label: 'Constellations', value: fmtInt(d.constellations) },
99 + { label: 'First launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.first_launch)}</span> },
100 + { label: 'Last launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.last_launch)}</span> },
101 + { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },
102 + ]}
103 + />
104 +
105 + <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
106 + <div className="min-w-0">
107 + <Block eyebrow="Growth" title="Fleet growth by launch year" id="growth">
108 + {growth.length ? (
109 + <StackedBars data={growth} keys={['still_active', 'retired']} labels={{ still_active: 'Launched · still active', retired: 'Launched · no longer active' }} title="Payloads launched per year, split by current status" height={200} />
110 + ) : (
111 + <Unavailable what="Fleet growth" />
112 + )}
113 + </Block>
114 + <Block eyebrow="Programmes" title={`Constellations · ${fmtInt(d.constellations)}`} id="constellations">
115 + <ConstellationsMiniTable rows={d.constellations_list} />
116 + </Block>
117 + <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">
118 + <LaunchesTable rows={d.launches_list} />
119 + {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}
120 + </Block>
121 + <Block eyebrow="Fleet" title="Fleet sample" id="fleet" action={{ href: routes.satellites(`operator=${encodeURIComponent(d.slug)}`), label: 'All satellites operated' }}>
122 + <SatellitesTable rows={d.fleet_sample} columns={['orbit', 'mission', 'perigee']} />
123 + </Block>
124 + <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>
125 + <EventsList events={d.events} />
126 + </Block>
127 + </div>
128 +
129 + <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">
130 + <Block eyebrow="Fleet" title="By status">
131 + <Donut data={statusData} title="Payloads by status" total={num(d.total_payloads) ?? undefined} size={140} />
132 + </Block>
133 + <Block eyebrow="Fleet" title={<span className="inline-flex items-center gap-2">By orbit class <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>
134 + <Donut data={orbitData} title="Payloads on orbit by orbit class" size={140} />
135 + </Block>
136 + <Block eyebrow="Fleet" title={<span className="inline-flex items-center gap-2">By mission <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>
137 + <HBars data={missionData} />
138 + </Block>
139 + <Block eyebrow="Ground" title="Launch sites">
140 + <SitesTable rows={d.launch_sites} />
141 + </Block>
142 + <Block eyebrow="Monitoring" title="Recent announcements">
143 + <PlannedUnavailable what="Recent announcements" note="Company monitoring (press releases, filings, official channels) is planned. Nothing is shown until a source is connected." />
144 + </Block>
145 + </aside>
146 + </div>
147 + </Container>
148 + );
149 +}
added apps/web/src/app/operators/page.tsx +150 −0
@@ -0,0 +1,150 @@
1 +import type { Metadata } from 'next';
2 +import { Search } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { ChipRow, EmptyRow, ScrollTable, entityMetadata, first, type Params } from '@/components/entities/shared';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { Container, PageHeader } from '@/components/ui/section';
7 +import { Unavailable } from '@/components/ui/unavailable';
8 +import { api, safe } from '@/lib/api';
9 +import { fmtDate, fmtInt, num, titleCase } from '@/lib/format';
10 +import { routes } from '@/lib/site';
11 +import type { OperatorRow } from '@/lib/types';
12 +
13 +type SearchParams = Record<string, string | string[] | undefined>;
14 +
15 +const SORTS: { value: string | undefined; label: string }[] = [
16 + { value: undefined, label: 'Active payloads' },
17 + { value: 'total', label: 'Total payloads' },
18 + { value: 'growth', label: 'Growth 365 d' },
19 + { value: 'name', label: 'Name' },
20 +];
21 +const SORT_NOUN: Record<string, string> = { '': 'active payloads', total: 'total payloads launched', growth: 'payloads launched in the last 365 days', name: 'name' };
22 +const KINDS = ['operator', 'agency', 'military', 'manufacturer', 'launch_provider'];
23 +
24 +function parse(sp: SearchParams): Params {
25 + return { sort: first(sp.sort), kind: first(sp.kind), country: first(sp.country), q: first(sp.q)?.trim() || undefined, page: first(sp.page) };
26 +}
27 +
28 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<Metadata> {
29 + const p = parse(await searchParams);
30 + const bits = [p.kind && titleCase(p.kind), p.country && titleCase(p.country), p.q && `“${p.q}”`].filter(Boolean).join(' · ');
31 + return entityMetadata({
32 + title: bits ? `Satellite operators — ${bits}` : 'Satellite operators, agencies and military programmes — ranked',
33 + description: 'Every organisation operating satellites, ranked by active payloads: commercial operators, space agencies and military programmes with fleet size, constellations, launches and 365-day growth.',
34 + path: routes.operators(),
35 + });
36 +}
37 +
38 +export default async function OperatorsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
39 + const params = parse(await searchParams);
40 + const page = Math.max(1, Number(params.page) || 1);
41 + const res = await safe(api.operators({ sort: params.sort, kind: params.kind, country: params.country, q: params.q, page }));
42 + const rows = res?.data ?? [];
43 + const total = res?.pagination.total ?? null;
44 + const base = routes.operators();
45 + const activeSum = rows.reduce((s, r) => s + (num(r.active_payloads) ?? 0), 0);
46 +
47 + return (
48 + <Container wide>
49 + <PageHeader
50 + eyebrow="Operators"
51 + title="Satellite operators"
52 + lede={
53 + total !== null ? (
54 + <>
55 + {fmtInt(total)} organisations{params.q ? ` matching “${params.q}”` : params.kind || params.country ? ' in this selection' : ''} — commercial operators, agencies and military programmes — ranked by {SORT_NOUN[params.sort ?? ''] ?? 'active payloads'}.
56 + {rows.length > 0 && <> The {fmtInt(rows.length)} shown here operate {fmtInt(activeSum)} active payloads.</>}
57 + </>
58 + ) : (
59 + 'Operator rankings are temporarily unavailable.'
60 + )
61 + }
62 + >
63 + <form action={base} method="get" role="search" className="mt-6 flex max-w-lg items-stretch gap-2">
64 + {params.sort && <input type="hidden" name="sort" value={params.sort} />}
65 + {params.kind && <input type="hidden" name="kind" value={params.kind} />}
66 + {params.country && <input type="hidden" name="country" value={params.country} />}
67 + <label className="relative flex-1">
68 + <span className="sr-only">Search operators</span>
69 + <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-3" aria-hidden />
70 + <input type="search" name="q" defaultValue={params.q ?? ''} placeholder="Search operators, agencies, aliases…" className="h-11 w-full rounded-md border border-rule bg-plane-2 pl-9 pr-3 text-sm text-ink placeholder:text-ink-3" />
71 + </label>
72 + <button type="submit" className="h-11 rounded-md bg-accent px-4 text-sm font-medium text-accent-ink">Search</button>
73 + {params.q && (
74 + <Link href={base} className="inline-flex h-11 items-center rounded-md border border-rule px-3 text-sm text-ink-2 hover:bg-plane-2">Clear</Link>
75 + )}
76 + </form>
77 + </PageHeader>
78 +
79 + <div className="flex flex-col gap-2 border-y border-rule py-2">
80 + <ChipRow label="Sort" paramKey="sort" base={base} params={params} current={params.sort} options={SORTS} />
81 + <ChipRow label="Kind" paramKey="kind" base={base} params={params} current={params.kind} options={[{ value: undefined, label: 'All' }, ...KINDS.map((k) => ({ value: k, label: titleCase(k) }))]} />
82 + {params.country && <ChipRow label="Country" paramKey="country" base={base} params={params} current={params.country} options={[{ value: undefined, label: 'All countries' }, { value: params.country, label: titleCase(params.country) }]} />}
83 + </div>
84 +
85 + <div className="py-6">
86 + {!res ? <Unavailable what="Operator rankings" /> : <OperatorTable rows={rows} offset={(page - 1) * (res.pagination.page_size || 100)} />}
87 + {res && (
88 + <Pagination
89 + className="mt-4"
90 + page={res.pagination.page}
91 + pages={res.pagination.pages}
92 + total={res.pagination.total}
93 + pageSize={res.pagination.page_size}
94 + makeHref={(p) => {
95 + const q = new URLSearchParams();
96 + for (const [k, v] of Object.entries(params)) if (v && k !== 'page') q.set(k, v);
97 + if (p > 1) q.set('page', String(p));
98 + const s = q.toString();
99 + return s ? `${base}?${s}` : base;
100 + }}
101 + />
102 + )}
103 + </div>
104 + </Container>
105 + );
106 +}
107 +
108 +function OperatorTable({ rows, offset }: { rows: OperatorRow[]; offset: number }) {
109 + return (
110 + <ScrollTable>
111 + <table className="data-table stack">
112 + <thead>
113 + <tr>
114 + <th className="num max-md:hidden!">#</th>
115 + <th>Operator</th>
116 + <th>Kind</th>
117 + <th>Country</th>
118 + <th className="num">Active</th>
119 + <th className="num max-md:hidden!">On orbit</th>
120 + <th className="num">Total</th>
121 + <th className="num">Constellations</th>
122 + <th className="num">Launches</th>
123 + <th className="num">365 d</th>
124 + <th className="max-md:hidden!">First launch</th>
125 + <th>Last launch</th>
126 + </tr>
127 + </thead>
128 + <tbody>
129 + {rows.length === 0 && <EmptyRow colSpan={12}>No operator matches this query.</EmptyRow>}
130 + {rows.map((o, i) => (
131 + <tr key={o.id}>
132 + <td data-label="Rank" className="num mono text-xs text-ink-3 max-md:hidden!">{offset + i + 1}</td>
133 + <td className="primary" data-label="Operator"><Link href={routes.operator(o.slug)} className="link font-medium">{o.name}</Link></td>
134 + <td data-label="Kind" className="text-ink-2">{titleCase(o.kind)}</td>
135 + <td data-label="Country" className="text-ink-2">{o.country_slug ? <Link href={routes.country(o.country_slug)} className="link">{o.country_name}</Link> : o.country_name ?? '—'}</td>
136 + <td data-label="Active payloads" className="num tnum font-medium text-active">{fmtInt(o.active_payloads)}</td>
137 + <td data-label="On orbit" className="num tnum max-md:hidden!">{fmtInt(o.on_orbit_payloads)}</td>
138 + <td data-label="Total" className="num tnum">{fmtInt(o.total_payloads)}</td>
139 + <td data-label="Constellations" className="num tnum">{fmtInt(o.constellations)}</td>
140 + <td data-label="Launches" className="num tnum">{fmtInt(o.launches)}</td>
141 + <td data-label="Launched 365 d" className="num tnum">{fmtInt(o.payloads_last_365d)}</td>
142 + <td data-label="First launch" className="tnum text-ink-2 max-md:hidden!">{fmtDate(o.first_launch)}</td>
143 + <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(o.last_launch)}</td>
144 + </tr>
145 + ))}
146 + </tbody>
147 + </table>
148 + </ScrollTable>
149 + );
150 +}
modified apps/web/src/app/page.tsx +73 −6
@@ -1,10 +1,77 @@
1 −import { Container, PageHeader } from '@/components/ui/section';
1 +import type { Metadata } from 'next';
2 +import { LatestLaunches, RecentEvents, RecentReentries } from '@/components/home/activity';
3 +import { OrbitalDensity } from '@/components/home/density';
4 +import { FeaturedObjects } from '@/components/home/featured';
5 +import { HomeHero } from '@/components/home/hero';
6 +import { HeadlineMetrics } from '@/components/home/metrics';
7 +import { TopConstellations, TopCountries, TopOperators } from '@/components/home/rankings';
8 +import { ApiTeaser, DataSources } from '@/components/home/sources';
9 +import { Container } from '@/components/ui/section';
10 +import { Unavailable } from '@/components/ui/unavailable';
11 +import { api, safe } from '@/lib/api';
12 +import { fmtAgo, fmtDateTime } from '@/lib/format';
13 +import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';
14 +
15 +export const revalidate = 120;
16 +
17 +export const metadata: Metadata = {
18 + title: { absolute: `${SITE_NAME} — ${TAGLINE}` },
19 + description: DESCRIPTION,
20 + alternates: { canonical: '/' },
21 + openGraph: { title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION, url: SITE_URL, type: 'website' },
22 + twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
23 +};
24 +
25 +export default async function HomePage() {
26 + const res = await safe(api.home());
27 + const home = res?.data ?? null;
2 28
3 −/** Placeholder — replaced by the real homepage (components/home). */
4 −export default function HomePlaceholder() {
5 29 return (
6 − <Container>
7 − <PageHeader eyebrow="SatelliteIndex" title="Building…" />
8 − </Container>
30 + <>
31 + <HomeHero home={home} />
32 + <Container>
33 + {!home ? (
34 + <div className="py-12">
35 + <Unavailable what="Catalogue statistics" />
36 + </div>
37 + ) : (
38 + <>
39 + <HeadlineMetrics home={home} />
40 +
41 + <div className="grid grid-cols-[minmax(0,1fr)] gap-12 py-12 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] lg:gap-16">
42 + <FeaturedObjects items={home.trending} />
43 + <TopConstellations items={home.top_constellations} />
44 + </div>
45 +
46 + <div className="grid grid-cols-[minmax(0,1fr)] gap-12 border-t border-rule py-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-16">
47 + <LatestLaunches items={home.latest_launches} />
48 + <RecentEvents items={home.events} />
49 + </div>
50 +
51 + <div className="grid grid-cols-[minmax(0,1fr)] gap-12 border-t border-rule py-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-16">
52 + <TopCountries items={home.top_countries} />
53 + <TopOperators items={home.top_operators} />
54 + </div>
55 +
56 + <div className="border-t border-rule py-12">
57 + <OrbitalDensity buckets={home.orbital_buckets} />
58 + </div>
59 +
60 + <div className="border-t border-rule py-12">
61 + <RecentReentries items={home.reentries} />
62 + </div>
63 +
64 + <div className="grid grid-cols-[minmax(0,1fr)] gap-12 border-t border-rule py-12 lg:grid-cols-[minmax(0,6fr)_minmax(0,6fr)] lg:gap-16">
65 + <DataSources sources={home.sources} />
66 + <ApiTeaser />
67 + </div>
68 +
69 + <p className="mono border-t border-rule py-6 text-2xs text-ink-3">
70 + Statistics snapshot computed {fmtAgo(home.computed_at)} · {fmtDateTime(home.computed_at)} · page refreshes every 2 min
71 + </p>
72 + </>
73 + )}
74 + </Container>
75 + </>
9 76 );
10 77 }
added apps/web/src/app/privacy/page.tsx +55 −0
@@ -0,0 +1,55 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { KeyValues, Prose } from '@/components/meta/prose';
4 +import { Container, PageHeader, Section } from '@/components/ui/section';
5 +import { CONTACT_EMAIL, SITE_URL, routes } from '@/lib/site';
6 +
7 +export const metadata: Metadata = {
8 + title: 'Privacy',
9 + description: 'SatelliteIndex has no accounts, no tracking cookies and no advertising. What little we store, listed plainly.',
10 + alternates: { canonical: `${SITE_URL}/privacy` },
11 + openGraph: { title: 'Privacy | SatelliteIndex', description: 'No accounts, no tracking cookies, no advertising. What little we store, listed plainly.', url: `${SITE_URL}/privacy` },
12 + twitter: { card: 'summary', title: 'Privacy | SatelliteIndex', description: 'No accounts, no tracking cookies, no advertising.' },
13 +};
14 +
15 +export default function PrivacyPage() {
16 + return (
17 + <Container>
18 + <PageHeader eyebrow="Privacy" title="What we store, and what we don't" lede="SatelliteIndex is a read-only reference site. There are no user accounts, no advertising, no third-party analytics and no tracking cookies." />
19 +
20 + <Section eyebrow="Summary" title="In one table" className="pt-0">
21 + <KeyValues
22 + rows={[
23 + { k: 'Accounts', v: 'None. Nothing on the public site requires signing in.' },
24 + { k: 'Cookies', v: <>None for visitors. The only cookie the site can set is <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-[13px]">si_admin</code>, an httpOnly session cookie created when the operator signs in to the private admin console. It contains a hash, not the token, and expires after 12 hours.</> },
25 + { k: 'Analytics', v: 'No third-party analytics or advertising scripts are loaded.' },
26 + { k: 'Page-view counter', v: 'Anonymous. When you open a satellite, constellation, operator, country or launch page, the browser sends a beacon with only the entity id; the server increments a counter keyed by (day, entity type, entity id). No IP address, user agent, referrer or session is stored with it. It powers the “trending” list and nothing else.' },
27 + { k: 'Server logs', v: 'The API writes request logs with a random request id, method, path, status and timing, used for debugging and abuse prevention. The client IP is used in memory for rate limiting and is not persisted with the page-view counter. Logs are rotated and are not shared.' },
28 + { k: 'Search', v: 'Search queries are processed to return results and are not stored per user.' },
29 + { k: 'Third parties', v: 'Fonts and scripts are served from this site. Data upstream (CelesTrak and other sources) is fetched by our servers, never by your browser.' },
30 + { k: 'Location', v: 'Hosted in Canada (MacLustr). Public traffic terminates on a gateway in Beauharnois, Québec.' },
31 + ]}
32 + />
33 + </Section>
34 +
35 + <Section eyebrow="Details" title="Admin console">
36 + <Prose>
37 + <p>
38 + The admin area under <code>/admin</code> is for the operator only. Signing in sets the <code>si_admin</code> cookie described above (httpOnly, SameSite=Lax, Secure in production). Signing out clears it. Admin actions (running a connector, resolving a duplicate) are recorded server-side for auditability; they are not linked to any visitor.
39 + </p>
40 + </Prose>
41 + </Section>
42 +
43 + <Section eyebrow="Your rights" title="Questions">
44 + <Prose>
45 + <p>
46 + Because we do not hold personal data about visitors, there is nothing to export or delete. If you believe something on this site concerns you personally, or you have any question about this page, write to <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a>.
47 + </p>
48 + <p className="text-ink-3">
49 + See also the <Link href={routes.terms()} className="link">terms of use</Link>. This page will be updated if the practices above change; the last change is reflected in the site&rsquo;s version history.
50 + </p>
51 + </Prose>
52 + </Section>
53 + </Container>
54 + );
55 +}
added apps/web/src/app/rankings/page.tsx +115 −0
@@ -0,0 +1,115 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { HBars } from '@/components/charts/charts';
4 +import { DEFAULT_METRIC, findMetric, METRICS, type Row } from '@/components/stats/rankings-config';
5 +import { Chips, Derived, InlineRank, Note } from '@/components/stats/shared';
6 +import { Container, PageHeader, Section } from '@/components/ui/section';
7 +import { Unavailable } from '@/components/ui/unavailable';
8 +import { api, safe } from '@/lib/api';
9 +import { fmtAgo, fmtInt } from '@/lib/format';
10 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
11 +
12 +export const revalidate = 600;
13 +
14 +type Search = Promise<{ metric?: string | string[] }>;
15 +
16 +function pick(v: string | string[] | undefined): string | undefined {
17 + return Array.isArray(v) ? v[0] : v;
18 +}
19 +
20 +export async function generateMetadata({ searchParams }: { searchParams: Search }): Promise<Metadata> {
21 + const m = findMetric(pick((await searchParams).metric) ?? DEFAULT_METRIC);
22 + const url = `${SITE_URL}${m.key === DEFAULT_METRIC ? routes.rankings() : routes.rankings(m.key)}`;
23 + const title = `${m.title} — Rankings`;
24 + return {
25 + title,
26 + description: m.description,
27 + alternates: { canonical: url },
28 + openGraph: { title: `${title} | ${SITE_NAME}`, description: m.description, url, type: 'website', siteName: SITE_NAME },
29 + twitter: { card: 'summary_large_image', title: `${title} | ${SITE_NAME}`, description: m.description },
30 + };
31 +}
32 +
33 +export default async function RankingsPage({ searchParams }: { searchParams: Search }) {
34 + const metricKey = pick((await searchParams).metric) ?? DEFAULT_METRIC;
35 + const m = findMetric(metricKey);
36 + const res = await safe(api.rankings(m.key, 50));
37 + const rows: Row[] = res?.data.rows ?? [];
38 + const top = rows.slice(0, 10).map((r) => ({ label: m.name(r), value: m.primary(r), href: m.href(r) ?? undefined }));
39 + const chips = METRICS.map((x) => ({ href: x.key === DEFAULT_METRIC ? routes.rankings() : routes.rankings(x.key), label: x.label, active: x.key === m.key }));
40 + const hasDerived = m.columns.some((c) => c.derived);
41 +
42 + return (
43 + <Container>
44 + <PageHeader eyebrow="Rankings" title={m.title} lede={m.description}>
45 + <div className="mt-6">
46 + <Chips items={chips} ariaLabel="Ranking metric" />
47 + </div>
48 + </PageHeader>
49 +
50 + {m.warn && <Note tone="warn" className="mb-6">{m.warn}</Note>}
51 +
52 + {!res ? (
53 + <div className="py-6">
54 + <Unavailable what="Ranking" />
55 + </div>
56 + ) : rows.length === 0 ? (
57 + <div className="py-6">
58 + <Unavailable what="Ranking rows" />
59 + </div>
60 + ) : (
61 + <>
62 + <Section eyebrow="Top 10" title={m.primaryLabel} className="pt-0 md:pt-0">
63 + <HBars data={top} valueFormat={m.primaryFormat ?? fmtInt} />
64 + </Section>
65 +
66 + <Section eyebrow={`${fmtInt(rows.length)} rows`} title="Full ranking" className="pt-0 md:pt-0">
67 + <div className="overflow-x-hidden">
68 + <table className="data-table stack text-sm">
69 + <thead>
70 + <tr>
71 + {m.columns.map((c) => (
72 + <th key={c.key} className={c.num ? 'num' : undefined}>
73 + {c.label}
74 + {c.derived && <Derived className="ml-1.5 normal-case" />}
75 + </th>
76 + ))}
77 + </tr>
78 + </thead>
79 + <tbody>
80 + {rows.map((r, i) => (
81 + <tr key={`${m.key}-${i}`}>
82 + {m.columns.map((c, ci) => (
83 + <td key={c.key} data-label={ci === 0 ? undefined : c.label} className={[c.num ? 'num tnum' : '', ci === 0 ? 'primary' : ''].join(' ').trim() || undefined}>
84 + {ci === 0 ? (
85 + <div className="flex items-start">
86 + <InlineRank n={i + 1} />
87 + {c.render(r)}
88 + </div>
89 + ) : (
90 + c.render(r)
91 + )}
92 + </td>
93 + ))}
94 + </tr>
95 + ))}
96 + </tbody>
97 + </table>
98 + </div>
99 + {(m.note || hasDerived) && (
100 + <Note className="mt-4">
101 + {m.note ?? (
102 + <>
103 + Columns marked <Derived className="mx-1" /> are computed by SatelliteIndex.
104 + </>
105 + )}{' '}
106 + <Link href={routes.methodology()} className="text-accent hover:underline">Methodology</Link>
107 + </Note>
108 + )}
109 + <p className="mono mt-3 text-xs text-ink-3">Generated {fmtAgo(res.meta.generated_at)} · limit 50 · source: SatelliteIndex canonical catalogue (CelesTrak SATCAT + GP)</p>
110 + </Section>
111 + </>
112 + )}
113 + </Container>
114 + );
115 +}
added apps/web/src/app/reentries/page.tsx +184 −0
@@ -0,0 +1,184 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Bars, StackedBars } from '@/components/charts/charts';
4 +import { ChartBlock, Chips, Disclaimer, Note, StatGrid } from '@/components/stats/shared';
5 +import { TypeBadge } from '@/components/ui/badges';
6 +import { Pagination } from '@/components/ui/pagination';
7 +import { Container, PageHeader, Section, Stat } from '@/components/ui/section';
8 +import { Unavailable } from '@/components/ui/unavailable';
9 +import { api, safe } from '@/lib/api';
10 +import { fmt2, fmtAgo, fmtDate, fmtInt, fmtKm, num } from '@/lib/format';
11 +import { OBJECT_TYPE_LABELS, routes, SITE_NAME, SITE_URL } from '@/lib/site';
12 +
13 +export const revalidate = 300;
14 +
15 +type Search = Promise<{ page?: string | string[]; object_type?: string | string[]; days?: string | string[] }>;
16 +const PAGE_SIZE = 50;
17 +const DAYS = [30, 90, 365, 3650] as const;
18 +const TYPES = ['PAYLOAD', 'DEBRIS', 'ROCKET_BODY', 'UNKNOWN'] as const;
19 +const pick = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
20 +
21 +const TITLE = 'Reentries — recently decayed objects and low-perigee watch';
22 +const 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.';
23 +
24 +export 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 +}
34 +
35 +function 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 +}
43 +const daysLabel = (d: number) => (d === 30 ? 'Last 30 d' : d === 90 ? 'Last 90 d' : d === 365 ? 'Last year' : 'Last 10 years');
44 +
45 +export 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;
51 +
52 + 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 }));
56 +
57 + 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 }));
59 +
60 + 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>
65 +
66 + {!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>
79 +
80 + <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>
91 +
92 + <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>
132 +
133 + <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>
172 +
173 + <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 +}
added apps/web/src/app/satellite/[slug]/opengraph-image.tsx +80 −0
@@ -0,0 +1,80 @@
1 +import { ImageResponse } from 'next/og';
2 +import { api, safe } from '@/lib/api';
3 +import { fmtDate, fmtInt } from '@/lib/format';
4 +import { OBJECT_TYPE_LABELS, ORBIT_CLASS_COLORS, STATUS_COLORS } from '@/lib/site';
5 +
6 +export const size = { width: 1200, height: 630 };
7 +export const contentType = 'image/png';
8 +export const alt = 'SatelliteIndex — satellite orbit card';
9 +
10 +// OG renderer cannot resolve CSS variables: mirror the hex tokens from globals.css.
11 +const HEX: Record<string, string> = {
12 + 'var(--active)': '#38d17f',
13 + 'var(--inactive)': '#7c869e',
14 + 'var(--ink-3)': '#6b7694',
15 + 'var(--warn)': '#f5a524',
16 + 'var(--danger)': '#ff5c6c',
17 + 'var(--accent-2)': '#8f7dff',
18 + 'var(--leo)': '#38d3ff',
19 + 'var(--meo)': '#8f7dff',
20 + 'var(--geo)': '#f5b544',
21 + 'var(--heo)': '#ff7ab6',
22 + 'var(--other)': '#7c869e',
23 +};
24 +const hex = (v: string | undefined, fallback: string) => (v ? HEX[v] ?? fallback : fallback);
25 +
26 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
27 + const { slug } = await params;
28 + const res = await safe(api.satellite(slug));
29 + const d = res?.data ?? null;
30 + const statusColor = hex(STATUS_COLORS[d?.status ?? 'UNKNOWN'], '#7c869e');
31 + const orbitColor = hex(ORBIT_CLASS_COLORS[d?.orbit_class ?? 'UNKNOWN'], '#7c869e');
32 + const live = d?.live && d.live.error == null ? d.live : null;
33 + const alt = live ? `${fmtInt(live.altitude_km)} km` : d?.orbital_state ? `${fmtInt(d.orbital_state.perigee_km)}–${fmtInt(d.orbital_state.apogee_km)} km` : '—';
34 +
35 + return new ImageResponse(
36 + (
37 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: 'linear-gradient(135deg, #0b1020 0%, #060912 70%)', color: '#eaf0ff', fontFamily: 'sans-serif' }}>
38 + <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
39 + <div style={{ width: 18, height: 18, borderRadius: 999, background: '#38d3ff', boxShadow: '0 0 0 6px rgba(56,211,255,0.18)' }} />
40 + <div style={{ fontSize: 26, letterSpacing: 4, textTransform: 'uppercase', color: '#a3aec8', fontWeight: 600 }}>SatelliteIndex</div>
41 + {d && (
42 + <div style={{ marginLeft: 'auto', fontSize: 24, color: '#6b7694', letterSpacing: 2 }}>{`NORAD ${d.norad_id ?? '—'}${d.cospar_id ? ` · ${d.cospar_id}` : ''}`}</div>
43 + )}
44 + </div>
45 +
46 + {d ? (
47 + <div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
48 + <div style={{ fontSize: d.name.length > 22 ? 64 : 88, fontWeight: 700, letterSpacing: -3, lineHeight: 1 }}>{d.name}</div>
49 + <div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
50 + <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 18px', borderRadius: 999, border: `2px solid ${statusColor}`, color: statusColor, fontSize: 26 }}>
51 + <div style={{ width: 12, height: 12, borderRadius: 999, background: statusColor }} />
52 + {d.status.charAt(0) + d.status.slice(1).toLowerCase()}
53 + </div>
54 + <div style={{ padding: '8px 18px', borderRadius: 8, background: 'rgba(160,180,230,0.10)', color: '#a3aec8', fontSize: 26 }}>{OBJECT_TYPE_LABELS[d.object_type] ?? d.object_type}</div>
55 + {d.orbit_class && <div style={{ padding: '8px 18px', borderRadius: 8, background: 'rgba(160,180,230,0.10)', color: orbitColor, fontSize: 26, fontWeight: 700 }}>{d.orbit_class}</div>}
56 + {d.operator_name && <div style={{ fontSize: 26, color: '#a3aec8' }}>{`· ${d.operator_name}`}</div>}
57 + </div>
58 + </div>
59 + ) : (
60 + <div style={{ fontSize: 72, fontWeight: 700 }}>Object not catalogued</div>
61 + )}
62 +
63 + <div style={{ display: 'flex', gap: 56, borderTop: '1px solid rgba(160,180,230,0.2)', paddingTop: 28 }}>
64 + {[
65 + ['Altitude', alt],
66 + ['Inclination', d?.orbital_state ? `${d.orbital_state.inclination.toFixed(2)}°` : d?.inclination_deg != null ? `${Number(d.inclination_deg).toFixed(2)}°` : '—'],
67 + ['Period', d?.orbital_state?.period_minutes ? `${d.orbital_state.period_minutes.toFixed(1)} min` : '—'],
68 + ['Launched', fmtDate(d?.launch_date)],
69 + ].map(([k, v]) => (
70 + <div key={k} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
71 + <div style={{ fontSize: 20, letterSpacing: 3, textTransform: 'uppercase', color: '#6b7694' }}>{k}</div>
72 + <div style={{ fontSize: 40, fontWeight: 600, color: '#eaf0ff' }}>{v}</div>
73 + </div>
74 + ))}
75 + </div>
76 + </div>
77 + ),
78 + { ...size },
79 + );
80 +}
added apps/web/src/app/satellite/[slug]/page.tsx +136 −0
@@ -0,0 +1,136 @@
1 +import type { Metadata } from 'next';
2 +import { notFound, permanentRedirect } from 'next/navigation';
3 +import { Suspense } from 'react';
4 +import { SectionSkeleton } from '@/components/satellite/skeleton';
5 +import { Hero, TelemetryPanel } from '@/components/satellite/hero';
6 +import { LiveMap } from '@/components/satellite/live-map';
7 +import { Block, Head, Note } from '@/components/satellite/primitives';
8 +import { EventsSection, HistorySection, IdentifiersSection, LaunchSection, MissionSection, OrbitSection, OwnershipSection, RegistrationSection, RelatedSection, SourcesSection } from '@/components/satellite/sections';
9 +import { ViewBeacon } from '@/components/satellite/view-beacon';
10 +import { Container } from '@/components/ui/section';
11 +import { api, ApiError, safe } from '@/lib/api';
12 +import { fmtDate, fmtInt } from '@/lib/format';
13 +import { MISSION_LABELS, OBJECT_TYPE_LABELS, routes, SITE_NAME, SITE_URL } from '@/lib/site';
14 +import type { SatelliteDetail } from '@/lib/types';
15 +
16 +type Params = { params: Promise<{ slug: string }> };
17 +
18 +async function load(slug: string): Promise<SatelliteDetail> {
19 + try {
20 + const res = await api.satellite(slug);
21 + return res.data;
22 + } catch (e) {
23 + if (e instanceof ApiError && e.notFound) notFound();
24 + throw e;
25 + }
26 +}
27 +
28 +function describe(d: SatelliteDetail): string {
29 + const bits: string[] = [];
30 + bits.push(`${d.name} (NORAD ${d.norad_id ?? '—'}${d.cospar_id ? `, COSPAR ${d.cospar_id}` : ''}) is a${d.status === 'ACTIVE' ? 'n active' : ` ${d.status.toLowerCase()}`} ${(OBJECT_TYPE_LABELS[d.object_type] ?? d.object_type).toLowerCase()}`);
31 + if (d.orbit_class) bits.push(`in ${d.orbit_class}`);
32 + if (d.operator_name) bits.push(`operated by ${d.operator_name}`);
33 + if (d.launch_date) bits.push(`launched ${fmtDate(d.launch_date)}${d.launch_site_name ? ` from ${d.launch_site_name}` : ''}`);
34 + let s = bits.join(' ') + '.';
35 + if (d.orbital_state) s += ` Perigee ${fmtInt(d.orbital_state.perigee_km)} km, apogee ${fmtInt(d.orbital_state.apogee_km)} km, inclination ${d.orbital_state.inclination.toFixed(2)}°.`;
36 + else if (d.decay_date) s += ` Decayed ${fmtDate(d.decay_date)}.`;
37 + s += ' Live position, orbital elements, history and sources on SatelliteIndex.';
38 + return s;
39 +}
40 +
41 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
42 + const { slug } = await params;
43 + const d = await safe(api.satellite(slug));
44 + if (!d) return { title: 'Satellite', robots: { index: false } };
45 + const s = d.data;
46 + const title = `${s.name} — Live Orbit, NORAD ${s.norad_id ?? '—'}`;
47 + const description = describe(s);
48 + const canonical = routes.satellite(s.slug);
49 + return {
50 + title,
51 + description,
52 + alternates: { canonical },
53 + openGraph: { title, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME },
54 + twitter: { card: 'summary_large_image', title, description },
55 + };
56 +}
57 +
58 +/** Streams after the shell: the history endpoint is the slowest call and must never delay the redirect/404 decision. */
59 +async function OrbitWithHistory({ d }: { d: SatelliteDetail }) {
60 + const history = d.norad_id !== null ? ((await safe(api.satelliteHistory(d.slug)))?.data ?? null) : null;
61 + return <OrbitSection d={d} history={history} />;
62 +}
63 +
64 +export default async function SatellitePage({ params }: Params) {
65 + const { slug } = await params;
66 + const d = await load(slug);
67 + if (d.redirected_from || d.slug !== slug) permanentRedirect(routes.satellite(d.slug));
68 +
69 + const ident = String(d.norad_id ?? d.slug);
70 + const hasElements = d.orbital_state !== null;
71 +
72 + const jsonLd = {
73 + '@context': 'https://schema.org',
74 + '@type': 'Thing',
75 + name: d.name,
76 + alternateName: d.aliases.map((a) => a.alias).filter((a) => a !== d.name),
77 + identifier: [
78 + d.norad_id !== null ? { '@type': 'PropertyValue', propertyID: 'NORAD', value: String(d.norad_id) } : null,
79 + d.cospar_id ? { '@type': 'PropertyValue', propertyID: 'COSPAR', value: d.cospar_id } : null,
80 + ].filter(Boolean),
81 + url: `${SITE_URL}${routes.satellite(d.slug)}`,
82 + description: describe(d),
83 + additionalType: 'https://schema.org/Dataset',
84 + subjectOf: { '@type': 'Dataset', name: `${d.name} orbital elements`, description: 'Latest two-line element set and derived orbital parameters', license: 'https://celestrak.org/NORAD/documentation/', isAccessibleForFree: true, creator: d.sources.map((s) => ({ '@type': 'Organization', name: s.name })) },
85 + };
86 +
87 + return (
88 + <Container wide>
89 + <ViewBeacon type="satellite" id={d.id} />
90 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
91 +
92 + <Hero d={d} />
93 +
94 + {/* Two-column terminal layout. DOM order = visual order on every breakpoint: live map → telemetry panel → deep sections. */}
95 + <div className="lg:grid lg:grid-cols-[minmax(0,1fr)_340px] lg:gap-x-10 xl:grid-cols-[minmax(0,1fr)_380px]">
96 + <Block id="live" className="lg:col-start-1 lg:row-start-1 lg:pt-0">
97 + <Head eyebrow="Live position" title={hasElements ? 'Ground track & current position' : 'Live position'}>
98 + {hasElements && <p className="mt-1 text-xs text-ink-3">Propagated client-side every 5 s from the latest element set (SGP4). Accuracy degrades with element age.</p>}
99 + </Head>
100 + {hasElements ? (
101 + <LiveMap ident={ident} name={d.name} initial={d.live && d.live.error == null ? d.live : null} sourceEpoch={d.orbital_state?.epoch ?? null} />
102 + ) : (
103 + <Note>
104 + Live position unavailable — {d.status === 'DECAYED' ? `this object re-entered the atmosphere${d.decay_date ? ` on ${fmtDate(d.decay_date)}` : ''} and is no longer tracked.` : 'no public orbital element set exists for this object (it may be classified, untracked or too small to catalogue).'}
105 + </Note>
106 + )}
107 + </Block>
108 +
109 + <aside className="lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:self-start lg:pt-0 lg:sticky lg:top-[calc(var(--header-h)+1rem)]" aria-label="Telemetry readout">
110 + <div className="py-6 lg:py-0">
111 + <TelemetryPanel d={d} />
112 + </div>
113 + </aside>
114 +
115 + <div className="lg:col-start-1 lg:row-start-2 divide-y divide-[color:var(--rule)]">
116 + <Suspense fallback={<SectionSkeleton rows={12} chart title="Loading orbital elements" />}>
117 + <OrbitWithHistory d={d} />
118 + </Suspense>
119 + <MissionSection d={d} />
120 + <OwnershipSection d={d} />
121 + <LaunchSection d={d} />
122 + <HistorySection d={d} />
123 + <RegistrationSection />
124 + <SourcesSection d={d} />
125 + <EventsSection d={d} />
126 + <RelatedSection d={d} />
127 + <IdentifiersSection d={d} />
128 + </div>
129 + </div>
130 +
131 + <p className="pb-10 pt-4 text-2xs text-ink-3">
132 + Orbit class, mission type and constellation membership are derived by SatelliteIndex ({MISSION_LABELS[d.mission_type ?? 'unknown'] ?? d.mission_type}) — see the <a className="hover:text-accent" href={routes.methodology()}>methodology</a>. Positions are propagated estimates, not tracking measurements; never use them for conjunction assessment.
133 + </p>
134 + </Container>
135 + );
136 +}
added apps/web/src/app/satellites/loading.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import { Bone, TableSkeleton } from '@/components/satellite/skeleton';
2 +import { Container } from '@/components/ui/section';
3 +
4 +export default function Loading() {
5 + return (
6 + <Container wide>
7 + <div className="pb-6 pt-8 md:pb-8 md:pt-12" role="status" aria-label="Loading satellites">
8 + <Bone className="h-3 w-40" />
9 + <Bone className="mt-3 h-10 w-2/3 max-w-lg md:h-14" />
10 + <Bone className="mt-6 h-11 w-full max-w-md" />
11 + </div>
12 + <Bone className="h-40 w-full rounded-lg" />
13 + <div className="mt-5 flex flex-wrap gap-1.5">
14 + {Array.from({ length: 10 }).map((_, i) => (
15 + <Bone key={i} className="h-9 w-24" />
16 + ))}
17 + </div>
18 + <div className="py-6">
19 + <TableSkeleton rows={14} />
20 + </div>
21 + </Container>
22 + );
23 +}
added apps/web/src/app/satellites/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Suspense } from 'react';
4 +import { ActiveFilters, FacetChips, FilterForm, href, parseQuery, titleFor, type SatQuery } from '@/components/satellite/explorer-filters';
5 +import { Derived } from '@/components/satellite/primitives';
6 +import { ResultsTable } from '@/components/satellite/results-table';
7 +import { SearchBox } from '@/components/satellite/search-box';
8 +import { Pagination } from '@/components/ui/pagination';
9 +import { Container, PageHeader } from '@/components/ui/section';
10 +import { Unavailable } from '@/components/ui/unavailable';
11 +import { api, safe } from '@/lib/api';
12 +import { fmtInt } from '@/lib/format';
13 +import { routes, SITE_URL } from '@/lib/site';
14 +
15 +const PAGE_SIZE = 50;
16 +const FACET_KEYS = ['status', 'object_type', 'orbit_class', 'mission_type', 'country', 'operator', 'constellation', 'on_orbit', 'q'] as const;
17 +
18 +type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
19 +
20 +function apiQuery(q: SatQuery) {
21 + const page = Math.max(1, Number(q.page ?? '1') || 1);
22 + return { ...q, page, page_size: PAGE_SIZE, sort: q.sort ?? 'launch_date' };
23 +}
24 +function facetQuery(q: SatQuery) {
25 + return Object.fromEntries(FACET_KEYS.map((k) => [k, q[k]]));
26 +}
27 +
28 +export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
29 + const q = parseQuery(await searchParams);
30 + const filtered = Object.keys(q).filter((k) => k !== 'page' && k !== 'sort').length > 0;
31 + // Same request as the page body → deduplicated by the fetch cache, so labels (e.g. "Starlink") are real, not slugs.
32 + const facets = filtered ? ((await safe(api.satelliteFacets(facetQuery(q))))?.data ?? null) : null;
33 + const title = filtered ? `${titleFor(q, facets)} — Satellite explorer` : 'Satellite explorer — every catalogued object in Earth orbit';
34 + const description = 'Filter 70,000+ catalogued objects by status, object type, orbit class, mission, country, constellation and launch date. Real counts, live element sets, honest gaps.';
35 + const canonical = href(q, {}, false);
36 + return {
37 + title,
38 + description,
39 + alternates: { canonical },
40 + robots: q.page || q.q ? { index: false, follow: true } : undefined,
41 + openGraph: { title, description, url: `${SITE_URL}${canonical}` },
42 + twitter: { card: 'summary_large_image', title, description },
43 + };
44 +}
45 +
46 +export default async function SatellitesPage({ searchParams }: Props) {
47 + const q = parseQuery(await searchParams);
48 + const [list, facetsRes] = await Promise.all([safe(api.satellites(apiQuery(q))), safe(api.satelliteFacets(facetQuery(q)))]);
49 + const facets = facetsRes?.data ?? null;
50 + const total = list?.pagination.total ?? null;
51 + const filtered = Object.keys(q).filter((k) => k !== 'page' && k !== 'sort').length > 0;
52 +
53 + return (
54 + <Container wide>
55 + <PageHeader
56 + eyebrow={<>Catalogue · {total !== null ? <span className="tnum">{fmtInt(total)} objects</span> : 'count unavailable'}</>}
57 + title={filtered ? titleFor(q, facets) : 'Satellites & catalogued objects'}
58 + lede={filtered ? undefined : 'Every object in the public catalogue — payloads, rocket bodies, debris and stations — with live element sets where they exist. Filters are plain URL parameters: share, bookmark, script.'}
59 + >
60 + <div className="mt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
61 + <Suspense fallback={<div className="h-11 w-full max-w-md rounded-md border border-rule bg-plane-2" />}>
62 + <SearchBox initial={q.q ?? ''} />
63 + </Suspense>
64 + <p className="text-xs text-ink-3">
65 + Orbit class, mission and constellation are <Derived /> · <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>
66 + </p>
67 + </div>
68 + </PageHeader>
69 +
70 + <div className="space-y-5 pb-4">
71 + <FilterForm q={q} />
72 + <ActiveFilters q={q} />
73 + {facets ? <FacetChips facets={facets} q={q} /> : <Unavailable what="Facet counts" compact />}
74 + </div>
75 +
76 + <section className="py-6" aria-label="Results">
77 + {list === null ? (
78 + <Unavailable what="Catalogue results" />
79 + ) : list.data.length === 0 ? (
80 + <div className="rounded-lg border border-dashed border-rule-strong px-5 py-10 text-center">
81 + <p className="text-sm text-ink-2">No catalogued object matches these filters.</p>
82 + <p className="mt-1 text-xs text-ink-3">The count is real — nothing is hidden. Try removing a filter{q.launched_after || q.launched_before ? ' (launch-date filters depend on SATCAT launch dates, which are missing for some objects)' : ''}.</p>
83 + <Link href={routes.satellites()} className="mt-4 inline-flex h-10 items-center rounded-md border border-rule px-4 text-sm text-ink hover:bg-plane-2">Clear all filters</Link>
84 + </div>
85 + ) : (
86 + <>
87 + <Pagination page={list.pagination.page} pages={list.pagination.pages} total={list.pagination.total} pageSize={list.pagination.page_size} makeHref={(p) => href(q, { page: String(p) }, false)} className="mb-3" />
88 + <ResultsTable rows={list.data} />
89 + <Pagination page={list.pagination.page} pages={list.pagination.pages} total={list.pagination.total} pageSize={list.pagination.page_size} makeHref={(p) => href(q, { page: String(p) }, false)} className="mt-4" />
90 + </>
91 + )}
92 + </section>
93 +
94 + <p className="pb-10 text-2xs text-ink-3">
95 + Catalogue rows come from CelesTrak SATCAT; perigee, apogee and inclination shown here are catalogue values (the detail page shows the latest element set). Facet counts are computed live over the filtered set.
96 + </p>
97 + </Container>
98 + );
99 +}
added apps/web/src/app/search/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EXAMPLE_QUERIES, GroupedResults, Shortcuts } from '@/components/search/results';
4 +import { SearchForm } from '@/components/search/search-form';
5 +import { Container, PageHeader } from '@/components/ui/section';
6 +import { Unavailable } from '@/components/ui/unavailable';
7 +import { api, safe } from '@/lib/api';
8 +import { fmtInt } from '@/lib/format';
9 +import { routes, SITE_URL } from '@/lib/site';
10 +
11 +export const dynamic = 'force-dynamic';
12 +
13 +type SP = Promise<Record<string, string | string[] | undefined>>;
14 +
15 +function readQ(sp: Record<string, string | string[] | undefined>): string {
16 + const raw = Array.isArray(sp.q) ? sp.q[0] : sp.q;
17 + return (raw ?? '').trim().slice(0, 120);
18 +}
19 +
20 +export async function generateMetadata({ searchParams }: { searchParams: SP }): Promise<Metadata> {
21 + const q = readQ(await searchParams);
22 + const title = q ? `Search results for “${q}”` : 'Search satellites, operators, constellations and launches';
23 + return {
24 + title,
25 + description: q ? `SatelliteIndex search results for “${q}”: satellites, constellations, operators, countries, launches and launch sites.` : 'Search the SatelliteIndex catalogue by satellite name, NORAD or COSPAR identifier, operator, constellation, country or launch.',
26 + alternates: { canonical: q ? routes.search(q) : '/search' },
27 + robots: q ? { index: false, follow: true } : { index: true, follow: true },
28 + openGraph: { title, url: `${SITE_URL}${routes.search(q || undefined)}`, type: 'website' },
29 + };
30 +}
31 +
32 +export default async function SearchPage({ searchParams }: { searchParams: SP }) {
33 + const q = readQ(await searchParams);
34 + const res = q ? await safe(api.search(q, 50)) : null;
35 + const payload = res?.data ?? null;
36 + const failed = Boolean(q) && res === null;
37 +
38 + return (
39 + <Container>
40 + <PageHeader eyebrow="Search" title={q ? <>Results for <span className="text-accent">“{q}”</span></> : 'Search the index'} lede={q ? undefined : 'Satellites by name, NORAD or COSPAR identifier; constellations, operators, countries, launches and launch sites. Press ⌘K anywhere for the quick search.'} />
41 + <SearchForm q={q} className="max-w-2xl" autoFocus={!q} />
42 +
43 + <div className="py-8 md:py-10">
44 + {!q ? (
45 + <div>
46 + <p className="eyebrow mb-3">Try</p>
47 + <ul className="flex flex-wrap gap-2">
48 + {EXAMPLE_QUERIES.map((ex) => (
49 + <li key={ex}>
50 + <Link href={routes.search(ex)} className="inline-flex min-h-[40px] items-center rounded-full border border-rule bg-plane-2 px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
51 + {ex}
52 + </Link>
53 + </li>
54 + ))}
55 + </ul>
56 + <p className="mt-6 max-w-xl text-sm leading-relaxed text-ink-3">
57 + Launch vehicles are not indexed yet — a query such as “Falcon 9” only matches objects whose catalogue name contains those words. Browse the{' '}
58 + <Link href={routes.launches()} className="link">launch log</Link> or <Link href={routes.satellites()} className="link">filter the catalogue</Link> instead.
59 + </p>
60 + </div>
61 + ) : failed ? (
62 + <Unavailable what="Search" />
63 + ) : payload ? (
64 + <>
65 + <Shortcuts items={payload.shortcuts} />
66 + {payload.results.length === 0 ? (
67 + <div className="rounded-lg border border-dashed border-rule-strong px-5 py-10 text-center">
68 + <p className="text-base text-ink">No results for “{q}”.</p>
69 + <p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-ink-3">
70 + The index covers satellite names, NORAD and COSPAR identifiers, constellations, operators, countries, launches and launch sites. Launch vehicles (e.g. “Falcon 9”) are not indexed yet.
71 + </p>
72 + <div className="mt-5 flex flex-wrap justify-center gap-2">
73 + <Link href={routes.satellites(`q=${encodeURIComponent(q)}`)} className="inline-flex min-h-[44px] items-center rounded-md border border-rule-strong px-4 text-sm text-ink hover:bg-plane-2">Filter the catalogue for “{q}”</Link>
74 + <Link href={routes.explore()} className="inline-flex min-h-[44px] items-center rounded-md border border-rule px-4 text-sm text-ink-2 hover:bg-plane-2">Open the live globe</Link>
75 + </div>
76 + </div>
77 + ) : (
78 + <>
79 + <p className="tnum mb-6 text-xs text-ink-3">
80 + {fmtInt(payload.results.length)} result{payload.results.length === 1 ? '' : 's'}
81 + {payload.results.length >= 50 && ' (top 50 by relevance)'}
82 + </p>
83 + <GroupedResults results={payload.results} />
84 + </>
85 + )}
86 + </>
87 + ) : null}
88 + </div>
89 + </Container>
90 + );
91 +}
added apps/web/src/app/sitemap.xml/route.ts +10 −0
@@ -0,0 +1,10 @@
1 +import { SITEMAP_HEADERS, satelliteShardCount, toIndex } from '@/components/meta/sitemap-data';
2 +
3 +/** Sitemap index at /sitemap.xml (robots.txt points here): one entry per shard served by /sitemap/[id].xml. Hourly revalidation. */
4 +export const revalidate = 3600;
5 +
6 +export async function GET(): Promise<Response> {
7 + const shards = await satelliteShardCount();
8 + const ids = Array.from({ length: shards + 1 }, (_, i) => i);
9 + return new Response(toIndex(ids), { headers: SITEMAP_HEADERS });
10 +}
added apps/web/src/app/sitemap/[shard]/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { SITEMAP_HEADERS, satelliteShardCount, shardEntries, toUrlset } from '@/components/meta/sitemap-data';
2 +
3 +/** Sitemap shards: /sitemap/0.xml (static + entities), /sitemap/1.xml … /sitemap/N.xml (satellites, 5 000 per shard). Hourly revalidation. */
4 +export const revalidate = 3600;
5 +
6 +export async function GET(_req: Request, ctx: { params: Promise<{ shard: string }> }): Promise<Response> {
7 + const { shard: seg } = await ctx.params;
8 + const m = /^(\d+)\.xml$/.exec(seg);
9 + if (!m) return new Response('Not Found', { status: 404 });
10 + const shard = Number(m[1]);
11 + const shards = await satelliteShardCount();
12 + if (shard > shards) return new Response('Not Found', { status: 404 });
13 + return new Response(toUrlset(await shardEntries(shard)), { headers: SITEMAP_HEADERS });
14 +}
added apps/web/src/app/sources/page.tsx +207 −0
@@ -0,0 +1,207 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { fmtDuration, fmtInterval, fmtUntil } from '@/components/meta/connectors-table';
4 +import { ATTRIBUTION, FIELD_PRIORITY } from '@/components/meta/legal';
5 +import { Callout } from '@/components/meta/prose';
6 +import { FreshnessBadge } from '@/components/ui/badges';
7 +import { Container, PageHeader, Section } from '@/components/ui/section';
8 +import { Unavailable } from '@/components/ui/unavailable';
9 +import { api, safe } from '@/lib/api';
10 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
11 +import { SITE_URL, routes } from '@/lib/site';
12 +import type { SourceRow } from '@/lib/types';
13 +
14 +export const metadata: Metadata = {
15 + title: 'Data sources — provenance, licenses and freshness',
16 + description: 'Every upstream source behind SatelliteIndex: CelesTrak GP and SATCAT today, planned government and scientific catalogs, with license, attribution, sync schedule and freshness.',
17 + alternates: { canonical: `${SITE_URL}/sources` },
18 + openGraph: { title: 'Data sources | SatelliteIndex', description: 'Provenance, licenses, attribution and live freshness of every source feeding SatelliteIndex.', url: `${SITE_URL}/sources` },
19 + twitter: { card: 'summary', title: 'Data sources | SatelliteIndex', description: 'Provenance, licenses, attribution and live freshness of every source feeding SatelliteIndex.' },
20 +};
21 +
22 +const AUTHORITY_LABEL: Record<string, string> = { government: 'Government', intergovernmental: 'Intergovernmental', scientific: 'Scientific', secondary: 'Derived / secondary', commercial: 'Commercial' };
23 +const AUTHORITY_TONE: Record<string, string> = { government: 'var(--accent-2)', intergovernmental: 'var(--accent-2)', scientific: 'var(--accent)', secondary: 'var(--ink-3)', commercial: 'var(--warn)' };
24 +
25 +function AuthorityBadge({ type, official }: { type: string | null; official: boolean }) {
26 + const t = type ?? 'unknown';
27 + const color = AUTHORITY_TONE[t] ?? 'var(--inactive)';
28 + return (
29 + <span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ color, borderColor: `color-mix(in oklab, ${color} 35%, transparent)`, background: `color-mix(in oklab, ${color} 10%, transparent)` }}>
30 + {AUTHORITY_LABEL[t] ?? t}
31 + {official && <span className="text-ink-3">· official</span>}
32 + </span>
33 + );
34 +}
35 +
36 +function SourceCard({ s, now }: { s: SourceRow; now: number }) {
37 + const planned = !s.enabled;
38 + return (
39 + <article id={s.id} className="scroll-mt-[calc(var(--header-h)+1rem)] border-t border-rule py-6 md:py-8">
40 + <div className="grid gap-6 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
41 + <div className="min-w-0">
42 + <div className="flex flex-wrap items-center gap-2">
43 + <h2 className="text-lg font-semibold tracking-tight md:text-xl">{s.name}</h2>
44 + <AuthorityBadge type={s.authority_type} official={s.official} />
45 + <span className="rounded border border-rule px-1.5 py-0.5 text-[11px] uppercase tracking-wider text-ink-3">{s.type}</span>
46 + {planned ? <span className="rounded border border-warn/40 bg-warn-soft px-1.5 py-0.5 text-[11px] text-warn">Planned connector</span> : <FreshnessBadge status={s.freshness} />}
47 + </div>
48 + <dl className="mt-3 grid gap-2 text-sm sm:grid-cols-[120px_minmax(0,1fr)]">
49 + <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Website</dt>
50 + <dd className="min-w-0 break-all">
51 + {s.base_url ? (
52 + <a href={s.base_url} rel="noopener noreferrer" target="_blank" className="link">
53 + {s.base_url.replace(/^https?:\/\//, '')}
54 + </a>
55 + ) : (
56 + '—'
57 + )}
58 + </dd>
59 + <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">License</dt>
60 + <dd className="text-ink-2">{s.license ?? 'Unavailable'}</dd>
61 + <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Attribution</dt>
62 + <dd className="text-ink-2">
63 + {s.attribution_text ? <q className="italic">{s.attribution_text}</q> : '—'}
64 + {s.attribution_required && <span className="ml-2 text-xs text-ink-3">(required)</span>}
65 + </dd>
66 + <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Cadence</dt>
67 + <dd className="text-ink-2">{s.update_frequency_seconds ? `Upstream refresh about every ${fmtInterval(s.update_frequency_seconds)}` : 'On demand (derived after each ingestion)'}</dd>
68 + <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">Priority</dt>
69 + <dd className="tnum text-ink-2">{s.priority}</dd>
70 + </dl>
71 + {planned && <p className="mt-3 text-sm text-ink-3">Registered in the source catalog with its license and attribution, but no connector is running yet. Nothing on the site is sourced from it today.</p>}
72 + </div>
73 +
74 + <div className="min-w-0">
75 + <p className="eyebrow">Connectors</p>
76 + {!s.connectors || s.connectors.length === 0 ? (
77 + <p className="mt-2 text-sm text-ink-3">No connector registered.</p>
78 + ) : (
79 + <ul className="mt-2 divide-y divide-rule border-y border-rule">
80 + {s.connectors.map((c) => (
81 + <li key={c.name} className="py-2.5">
82 + <div className="flex flex-wrap items-center justify-between gap-2">
83 + <span className="mono text-sm text-ink">{c.name}</span>
84 + <span className="text-xs text-ink-3">every {fmtInterval(c.interval_seconds)}</span>
85 + </div>
86 + {c.description && <p className="mt-0.5 text-xs text-ink-3">{c.description}</p>}
87 + <dl className="mt-1.5 grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs sm:grid-cols-3">
88 + <dt className="text-ink-3">Last sync</dt>
89 + <dd className="sm:col-span-2" title={fmtDateTime(c.last_success_at)}>
90 + {fmtAgo(c.last_success_at, now)}
91 + </dd>
92 + <dt className="text-ink-3">Next run</dt>
93 + <dd className="sm:col-span-2">{c.enabled ? fmtUntil(c.next_run_at, now) : 'disabled'}</dd>
94 + <dt className="text-ink-3">Duration</dt>
95 + <dd className="sm:col-span-2">{fmtDuration(c.last_duration_ms)}</dd>
96 + {(c.consecutive_failures > 0 || c.circuit_open_until) && (
97 + <>
98 + <dt className="text-ink-3">Failures</dt>
99 + <dd className="text-warn sm:col-span-2">
100 + {fmtInt(c.consecutive_failures)} consecutive{c.circuit_open_until ? ` · circuit open until ${fmtDateTime(c.circuit_open_until)}` : ''}
101 + </dd>
102 + </>
103 + )}
104 + </dl>
105 + </li>
106 + ))}
107 + </ul>
108 + )}
109 + <div className="mt-4 grid grid-cols-3 gap-3">
110 + <div>
111 + <p className="eyebrow">Raw snapshots</p>
112 + <p className="tnum mt-0.5 text-lg font-semibold">{fmtInt(s.raw_snapshots)}</p>
113 + </div>
114 + <div>
115 + <p className="eyebrow">Provenance rows</p>
116 + <p className="tnum mt-0.5 text-lg font-semibold">{fmtInt(s.provenance_rows)}</p>
117 + </div>
118 + <div>
119 + <p className="eyebrow">Last snapshot</p>
120 + <p className="mt-0.5 text-sm" title={fmtDateTime(s.last_snapshot_at)}>
121 + {fmtAgo(s.last_snapshot_at, now)}
122 + </p>
123 + </div>
124 + </div>
125 + </div>
126 + </div>
127 + </article>
128 + );
129 +}
130 +
131 +export default async function SourcesPage() {
132 + const res = await safe(api.sources());
133 + const now = Date.now();
134 + const sources = res?.data ?? null;
135 + const active = sources?.filter((s) => s.enabled) ?? [];
136 + const planned = sources?.filter((s) => !s.enabled) ?? [];
137 + return (
138 + <Container>
139 + <PageHeader eyebrow="Transparency" title="Data sources" lede="Where every number on SatelliteIndex comes from: the upstream catalogs, their licenses and attribution requirements, how often we sync them and how fresh each feed is right now. Nothing is presented without provenance.">
140 + <p className="mt-4 text-sm text-ink-3">
141 + Live connector health: <Link href={routes.statusData()} className="link">/status/data</Link> · How data is processed: <Link href={routes.methodology()} className="link">/methodology</Link>
142 + {res && <span className="ml-2">· generated {fmtDateTime(res.meta.generated_at)}</span>}
143 + </p>
144 + </PageHeader>
145 +
146 + {sources === null ? (
147 + <Unavailable what="Source catalog" />
148 + ) : (
149 + <>
150 + <Section eyebrow="Active" title={`${active.length} source${active.length === 1 ? '' : 's'} feeding the index`} className="pt-0">
151 + <div>
152 + {active.map((s) => (
153 + <SourceCard key={s.id} s={s} now={now} />
154 + ))}
155 + </div>
156 + </Section>
157 +
158 + <Section eyebrow="Source priority" title="Which source wins for each field">
159 + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">
160 + Sources are ordered by priority; a higher-priority source overrides a lower one for the fields it covers, and every accepted value is recorded in <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-[13px]">field_provenance</code> with the source and observation time. Objects that disappear from a feed are never deleted.
161 + </p>
162 + <div className="mt-4 overflow-x-auto">
163 + <table className="data-table stack md:min-w-[760px]">
164 + <thead>
165 + <tr>
166 + <th>Field</th>
167 + <th>Source</th>
168 + <th>Note</th>
169 + </tr>
170 + </thead>
171 + <tbody>
172 + {FIELD_PRIORITY.map((f) => (
173 + <tr key={f.field}>
174 + <td className="primary text-sm text-ink">{f.field}</td>
175 + <td data-label="Source" className="text-sm">
176 + <a href={`#${f.sourceId}`} className="link">
177 + {f.source}
178 + </a>
179 + </td>
180 + <td data-label="Note" className="text-sm text-ink-2">
181 + {f.note}
182 + </td>
183 + </tr>
184 + ))}
185 + </tbody>
186 + </table>
187 + </div>
188 + </Section>
189 +
190 + {planned.length > 0 && (
191 + <Section eyebrow="Planned" title={`${planned.length} planned connectors`}>
192 + <Callout className="mt-0 mb-2">These catalogs are registered with their license and attribution so the schema is ready for them, but they are not ingested yet. They are listed here for honesty about coverage — no field on the site is currently attributed to them.</Callout>
193 + {planned.map((s) => (
194 + <SourceCard key={s.id} s={s} now={now} />
195 + ))}
196 + </Section>
197 + )}
198 +
199 + <Section eyebrow="Attribution" title="How we credit sources">
200 + <p className="max-w-3xl text-sm leading-relaxed text-ink-2">{ATTRIBUTION}</p>
201 + <p className="mt-3 max-w-3xl text-sm leading-relaxed text-ink-3">If you reuse data from this site or its API, carry the attribution texts above forward — see the <Link href={routes.developers()} className="link">API page</Link>.</p>
202 + </Section>
203 + </>
204 + )}
205 + </Container>
206 + );
207 +}
added apps/web/src/app/stats/page.tsx +100 −0
@@ -0,0 +1,100 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { StatsComposition } from '@/components/stats/stats-composition';
4 +import { StatsDensity } from '@/components/stats/stats-density';
5 +import { StatsHeadline } from '@/components/stats/stats-headline';
6 +import { StatsHistory } from '@/components/stats/stats-history';
7 +import { ConnectorStrip, Note } from '@/components/stats/shared';
8 +import { Container, PageHeader, Section } from '@/components/ui/section';
9 +import { Unavailable } from '@/components/ui/unavailable';
10 +import { api, safe } from '@/lib/api';
11 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
12 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
13 +
14 +export const revalidate = 300;
15 +
16 +const TITLE = 'Global statistics — satellites, debris, launches and orbital density';
17 +const DESC = 'Live statistics of everything in Earth orbit: active satellites, objects on orbit, debris, rocket bodies, launches since 1957, constellation shares and orbital density by altitude and inclination. Real catalogue data with transparent sources.';
18 +
19 +export async function generateMetadata(): Promise<Metadata> {
20 + const url = `${SITE_URL}${routes.stats()}`;
21 + return {
22 + title: TITLE,
23 + description: DESC,
24 + alternates: { canonical: url },
25 + openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url, type: 'website', siteName: SITE_NAME },
26 + twitter: { card: 'summary_large_image', title: `${TITLE} | ${SITE_NAME}`, description: DESC },
27 + };
28 +}
29 +
30 +export default async function StatsPage() {
31 + const [stats, density] = await Promise.all([safe(api.stats()), safe(api.density())]);
32 + const snapshot = stats?.data ?? null;
33 +
34 + return (
35 + <Container>
36 + <PageHeader eyebrow="Statistics" title="Everything in Earth orbit, by the numbers" lede="A dashboard of the whole catalogue: how many objects fly, who operates them, how fast the population grows and where it concentrates. Every number is read from the latest SatelliteIndex snapshot — nothing is estimated or hardcoded.">
37 + {snapshot && (
38 + <p className="mono mt-4 text-xs text-ink-3">
39 + Computed {fmtAgo(snapshot.computed_at)} · {fmtDateTime(snapshot.computed_at)} · {fmtInt(snapshot.connectors.length)} connectors
40 + </p>
41 + )}
42 + </PageHeader>
43 +
44 + {!snapshot ? (
45 + <div className="py-8">
46 + <Unavailable what="Statistics snapshot" />
47 + <Note className="mt-3">The statistics API did not answer. Live catalogue browsing may still work on <Link href={routes.satellites()} className="text-accent hover:underline">/satellites</Link>.</Note>
48 + </div>
49 + ) : (
50 + <>
51 + <Section eyebrow="Headline" title="Catalogue at a glance" className="pt-0 md:pt-0">
52 + <StatsHeadline snapshot={snapshot} />
53 + </Section>
54 +
55 + <Section eyebrow="Composition" title="What is up there" action={{ href: routes.satellites('on_orbit=true'), label: 'Browse objects' }}>
56 + <StatsComposition snapshot={snapshot} />
57 + </Section>
58 +
59 + <Section eyebrow="History" title="Seven decades of launches and decays" action={{ href: routes.launches(), label: 'All launches' }}>
60 + <StatsHistory snapshot={snapshot} />
61 + </Section>
62 +
63 + <Section eyebrow="Orbital density" title="Where objects concentrate" action={{ href: routes.debris(), label: 'Debris growth' }}>
64 + <StatsDensity density={density?.data ?? null} fallbackBuckets={snapshot.orbital_buckets} />
65 + </Section>
66 +
67 + <Section eyebrow="Rankings" title="Leaders and movers" action={{ href: routes.rankings(), label: 'All rankings' }}>
68 + <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
69 + {[
70 + { href: routes.rankings('constellations'), label: 'Largest constellations', hint: 'by active satellites' },
71 + { href: routes.rankings('operators'), label: 'Largest operators', hint: 'by active payloads' },
72 + { href: routes.rankings('countries'), label: 'Countries', hint: 'by active payloads' },
73 + { href: routes.rankings('countries-debris'), label: 'Debris by country', hint: 'objects on orbit' },
74 + { href: routes.rankings('launches'), label: 'Busiest launch sites', hint: 'launches since 1957' },
75 + { href: routes.rankings('fastest-growing'), label: 'Fastest-growing constellations', hint: 'launched last 365 d vs fleet' },
76 + { href: routes.rankings('congested-shells'), label: 'Most populated LEO shells', hint: '50 km bands, object counts' },
77 + { href: routes.rankings('launch-years'), label: 'Launch years', hint: 'launches and payloads per year' },
78 + ].map((r) => (
79 + <li key={r.href}>
80 + <Link href={r.href} className="flex min-h-[64px] flex-col justify-center border-t border-rule py-3 transition-colors hover:border-accent/60">
81 + <span className="text-sm font-medium text-ink">{r.label} →</span>
82 + <span className="text-xs text-ink-3">{r.hint}</span>
83 + </Link>
84 + </li>
85 + ))}
86 + </ul>
87 + </Section>
88 +
89 + <Section eyebrow="Sources" title="Connector freshness" action={{ href: routes.sources(), label: 'All sources' }}>
90 + <ConnectorStrip connectors={snapshot.connectors} />
91 + <Note className="mt-4">
92 + Fresh = last success within 3× the connector interval · aging = within 12× · stale beyond that. Orbit class, mission type, constellation membership, activity score and orbital density are derived metrics documented on{' '}
93 + <Link href={routes.methodology()} className="text-accent hover:underline">/methodology</Link>.
94 + </Note>
95 + </Section>
96 + </>
97 + )}
98 + </Container>
99 + );
100 +}
added apps/web/src/app/status/page.tsx +137 −0
@@ -0,0 +1,137 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ConnectorsTable, FreshnessLegend } from '@/components/meta/connectors-table';
4 +import { FreshnessBadge } from '@/components/ui/badges';
5 +import { Container, PageHeader, Section, Stat } from '@/components/ui/section';
6 +import { Unavailable } from '@/components/ui/unavailable';
7 +import { api, safe } from '@/lib/api';
8 +import { fmt1, fmtAgo, fmtDateTime, fmtInt, num } from '@/lib/format';
9 +import { SITE_URL, routes } from '@/lib/site';
10 +
11 +export const metadata: Metadata = {
12 + title: 'Status — platform health and data freshness',
13 + description: 'Live health of the SatelliteIndex API, database, cache and orbit service, and the freshness of every data connector.',
14 + alternates: { canonical: `${SITE_URL}/status` },
15 + openGraph: { title: 'Status | SatelliteIndex', description: 'Platform health and data freshness, live.', url: `${SITE_URL}/status` },
16 + twitter: { card: 'summary', title: 'Status | SatelliteIndex', description: 'Platform health and data freshness, live.' },
17 + robots: { index: true, follow: true },
18 +};
19 +
20 +export const dynamic = 'force-dynamic';
21 +
22 +function ComponentPill({ status }: { status: string }) {
23 + const ok = status === 'ok' || status === 'fresh';
24 + const warn = status === 'aging' || status === 'degraded';
25 + const color = ok ? 'var(--active)' : warn ? 'var(--warn)' : 'var(--danger)';
26 + return (
27 + <span className="mono inline-flex items-center gap-1.5 text-xs" style={{ color }}>
28 + <span className={`dot ${ok ? 'pulse' : ''}`} aria-hidden /> {status}
29 + </span>
30 + );
31 +}
32 +
33 +export default async function StatusPage() {
34 + const [health, status] = await Promise.all([safe(api.health()), safe(api.sourcesStatus())]);
35 + const now = Date.now();
36 + const comps = health?.components ?? {};
37 + const overall = health?.status ?? 'unavailable';
38 + const overallColor = overall === 'ok' ? 'var(--active)' : overall === 'degraded' ? 'var(--warn)' : 'var(--danger)';
39 + return (
40 + <Container>
41 + <PageHeader eyebrow="Operations" title="Status" lede="Two distinct questions, answered separately: is the platform up, and is the data current? A healthy API can still serve aging orbital elements when an upstream feed pauses — that is shown here, not hidden.">
42 + <p className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-ink-3">
43 + <span>
44 + Rendered {fmtDateTime(new Date(now).toISOString())} · <Link href={routes.status()} className="link">Refresh</Link>
45 + </span>
46 + <Link href={routes.statusData()} className="link">
47 + Data-only view →
48 + </Link>
49 + </p>
50 + </PageHeader>
51 +
52 + <Section eyebrow="Platform uptime" title="API and services" className="pt-0">
53 + {health === null ? (
54 + <Unavailable what="Health endpoint" />
55 + ) : (
56 + <>
57 + <div className="flex flex-wrap items-baseline gap-x-6 gap-y-2">
58 + <p className="display text-3xl md:text-5xl" style={{ color: overallColor }}>
59 + {overall === 'ok' ? 'Operational' : overall}
60 + </p>
61 + <p className="mono text-sm text-ink-3">
62 + version {health.version} · server time {fmtDateTime(health.time)}
63 + </p>
64 + </div>
65 + <div className="mt-6 grid grid-cols-2 gap-x-6 gap-y-5 md:grid-cols-4">
66 + <div>
67 + <p className="eyebrow">Database</p>
68 + <div className="mt-1">
69 + <ComponentPill status={String(comps.database?.status ?? 'unavailable')} />
70 + </div>
71 + <p className="tnum mt-1 text-sm text-ink-2">{comps.database?.satellites !== undefined ? `${fmtInt(comps.database.satellites as number)} satellites` : 'count unavailable'}</p>
72 + </div>
73 + <div>
74 + <p className="eyebrow">Redis cache</p>
75 + <div className="mt-1">
76 + <ComponentPill status={String(comps.redis?.status ?? 'unavailable')} />
77 + </div>
78 + <p className="mt-1 text-sm text-ink-2">response cache, locks, job queue</p>
79 + </div>
80 + <div>
81 + <p className="eyebrow">Orbit service</p>
82 + <div className="mt-1">
83 + <ComponentPill status={String(comps.orbit_service?.status ?? 'unavailable')} />
84 + </div>
85 + <p className="tnum mt-1 text-sm text-ink-2">{comps.orbit_service?.objects !== undefined ? `${fmtInt(comps.orbit_service.objects as number)} objects in the propagator` : 'object count unavailable'}</p>
86 + </div>
87 + <div>
88 + <p className="eyebrow">Data</p>
89 + <div className="mt-1">
90 + <ComponentPill status={String(comps.data?.status ?? 'unavailable')} />
91 + </div>
92 + <p className="mt-1 text-sm text-ink-2">worst connector freshness</p>
93 + </div>
94 + </div>
95 + </>
96 + )}
97 + </Section>
98 +
99 + <Section eyebrow="Data freshness" title="Connectors" action={{ href: routes.sources(), label: 'Sources & licenses' }}>
100 + {status === null ? (
101 + <Unavailable what="Connector status" />
102 + ) : (
103 + <>
104 + <div className="mb-6 grid grid-cols-2 gap-x-6 gap-y-5 md:grid-cols-4">
105 + <Stat label="Latest element epoch" value={<span className="text-xl md:text-2xl">{fmtAgo(status.data.orbit.latest_epoch, now)}</span>} hint={fmtDateTime(status.data.orbit.latest_epoch)} />
106 + <Stat label="Median element age" value={status.data.orbit.median_element_age_hours === null ? 'Unavailable' : `${fmt1(status.data.orbit.median_element_age_hours)} h`} hint="across the latest element set of every object" />
107 + <Stat label="Propagator objects" value={fmtInt(status.data.orbit.propagator_objects)} hint="loaded for live positions" />
108 + <Stat
109 + label="Connectors fresh"
110 + value={`${fmtInt(status.data.connectors.filter((c) => c.freshness === 'fresh').length)} / ${fmtInt(status.data.connectors.filter((c) => c.enabled).length)}`}
111 + hint={
112 + <span className="inline-flex flex-wrap gap-2">
113 + {status.data.connectors.filter((c) => c.enabled && c.freshness !== 'fresh').map((c) => (
114 + <FreshnessBadge key={c.name} status={c.freshness} label={`${c.name}: ${c.freshness}`} />
115 + ))}
116 + {status.data.connectors.every((c) => !c.enabled || c.freshness === 'fresh') && 'all enabled connectors are fresh'}
117 + </span>
118 + }
119 + />
120 + </div>
121 + <ConnectorsTable connectors={status.data.connectors} now={now} />
122 + <details className="mt-4 text-sm">
123 + <summary className="cursor-pointer py-1 text-ink-3 hover:text-ink">Freshness thresholds</summary>
124 + <div className="mt-2">
125 + <FreshnessLegend connectors={status.data.connectors.filter((c) => c.enabled)} />
126 + </div>
127 + </details>
128 + <p className="mt-3 text-xs text-ink-3">
129 + generated {fmtDateTime(status.meta.generated_at)} · request {status.meta.request_id}
130 + {num(status.data.orbit.median_element_age_hours) !== null && (num(status.data.orbit.median_element_age_hours) ?? 0) > 48 && <span className="ml-2 text-warn">Element sets are older than 48 h on median — live positions carry more uncertainty than usual.</span>}
131 + </p>
132 + </>
133 + )}
134 + </Section>
135 + </Container>
136 + );
137 +}
added apps/web/src/app/terms/page.tsx +66 −0
@@ -0,0 +1,66 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ATTRIBUTION, DISCLAIMER } from '@/components/meta/legal';
4 +import { Callout, Prose } from '@/components/meta/prose';
5 +import { Container, PageHeader, Section } from '@/components/ui/section';
6 +import { AUTHOR, CONTACT_EMAIL, SITE_URL, routes } from '@/lib/site';
7 +
8 +export const metadata: Metadata = {
9 + title: 'Terms of use',
10 + description: 'SatelliteIndex is an informational platform provided as is. Disclaimer, attribution of sources and limits of use.',
11 + alternates: { canonical: `${SITE_URL}/terms` },
12 + openGraph: { title: 'Terms of use | SatelliteIndex', description: 'Informational platform, provided as is. Disclaimer, attribution and limits of use.', url: `${SITE_URL}/terms` },
13 + twitter: { card: 'summary', title: 'Terms of use | SatelliteIndex', description: 'Informational platform, provided as is.' },
14 +};
15 +
16 +export default function TermsPage() {
17 + return (
18 + <Container>
19 + <PageHeader eyebrow="Terms" title="Terms of use" lede="Short, because the situation is simple: this is a free informational reference built from public catalogs, offered without warranty." />
20 +
21 + <Section eyebrow="1" title="Informational platform" className="pt-0">
22 + <Callout tone="warn" className="mt-0">
23 + {DISCLAIMER}
24 + </Callout>
25 + <Prose className="mt-4">
26 + <p>
27 + Positions are propagated with SGP4 from published element sets and degrade with the age of those elements; statuses, owners and launch data follow the upstream catalogs with their own latency; classifications such as orbit class, mission type and constellation membership are derived by documented rules and can be wrong for individual objects. Freshness and provenance are displayed on every page so you can judge each value yourself.
28 + </p>
29 + </Prose>
30 + </Section>
31 +
32 + <Section eyebrow="2" title="No warranty, limitation of liability">
33 + <Prose>
34 + <p>
35 + The site and its API are provided <strong>as is</strong> and <strong>as available</strong>, without warranty of any kind, express or implied, including accuracy, completeness, fitness for a particular purpose or uninterrupted availability. To the fullest extent permitted by law, {AUTHOR} accepts no liability for any loss or damage arising from the use of, or reliance on, the information published here.
36 + </p>
37 + </Prose>
38 + </Section>
39 +
40 + <Section eyebrow="3" title="Sources and attribution">
41 + <Prose>
42 + <p>{ATTRIBUTION}</p>
43 + <p>
44 + Upstream data remains subject to the terms of its publishers, listed with each source on <Link href={routes.sources()} className="link">/sources</Link>. If you reuse data obtained from SatelliteIndex, keep those attributions — in particular <em>“Orbital data courtesy of CelesTrak”</em> — and link back to this site for derived values.
45 + </p>
46 + </Prose>
47 + </Section>
48 +
49 + <Section eyebrow="4" title="Acceptable use of the API">
50 + <Prose>
51 + <p>
52 + The public API is free during the MVP phase and rate-limited per client (limits on <Link href={routes.developers()} className="link">/developers</Link>). Do not attempt to bypass rate limits, scrape the HTML pages in bulk when the API provides the same data, or access the private <code>/admin</code> area. Abusive clients may be blocked without notice.
53 + </p>
54 + </Prose>
55 + </Section>
56 +
57 + <Section eyebrow="5" title="Changes and contact">
58 + <Prose>
59 + <p>
60 + These terms may change as the platform evolves (for example when API keys or paid tiers are introduced); the current version is always the one published at this address. Questions: <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a>. See also the <Link href={routes.privacy()} className="link">privacy page</Link>.
61 + </p>
62 + </Prose>
63 + </Section>
64 + </Container>
65 + );
66 +}
added apps/web/src/components/admin/actions.tsx +129 −0
@@ -0,0 +1,129 @@
1 +'use client';
2 +
3 +import { useRouter } from 'next/navigation';
4 +import { useState, useTransition } from 'react';
5 +import { cn } from '@/lib/cn';
6 +import type { ReviewDecision } from './types';
7 +
8 +/**
9 + * Admin mutations. Each button POSTs to a Next route handler (same origin, session cookie) which forwards to FastAPI with the
10 + * server-side token, then refreshes the server components. Errors are shown inline — never swallowed.
11 + */
12 +async function post(path: string, body?: unknown): Promise<{ ok: true; data: Record<string, unknown> } | { ok: false; error: string }> {
13 + try {
14 + const res = await fetch(path, { method: 'POST', headers: body !== undefined ? { 'content-type': 'application/json' } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined });
15 + const json = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: { detail?: string; title?: string } };
16 + if (!res.ok) return { ok: false, error: json.error?.detail || json.error?.title || `HTTP ${res.status}` };
17 + return { ok: true, data: json };
18 + } catch (e) {
19 + return { ok: false, error: (e as Error).message };
20 + }
21 +}
22 +
23 +const btn = 'inline-flex min-h-9 items-center justify-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50';
24 +const btnNeutral = 'border-rule text-ink-2 hover:bg-plane-2 hover:text-ink';
25 +const btnAccent = 'border-accent/40 bg-accent-soft text-accent hover:bg-accent/20';
26 +const btnDanger = 'border-danger/40 bg-danger-soft text-danger hover:bg-danger/20';
27 +
28 +export function RunNowButton({ name, className }: { name: string; className?: string }) {
29 + const router = useRouter();
30 + const [pending, start] = useTransition();
31 + const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | null>(null);
32 + return (
33 + <span className={cn('inline-flex flex-wrap items-center gap-2', className)}>
34 + <button
35 + type="button"
36 + disabled={pending}
37 + className={cn(btn, btnAccent)}
38 + onClick={() =>
39 + start(async () => {
40 + setMsg(null);
41 + const r = await post(`/api/admin/connectors/${encodeURIComponent(name)}/run`);
42 + if (r.ok) {
43 + const queued = r.data.queued === true;
44 + setMsg({ tone: queued ? 'ok' : 'err', text: queued ? 'queued' : String(r.data.note ?? 'not queued') });
45 + router.refresh();
46 + } else setMsg({ tone: 'err', text: r.error });
47 + })
48 + }
49 + >
50 + {pending ? 'Queuing…' : 'Run now'}
51 + </button>
52 + {msg && <span className={cn('text-xs', msg.tone === 'ok' ? 'text-active' : 'text-danger')}>{msg.text}</span>}
53 + </span>
54 + );
55 +}
56 +
57 +export function EnabledToggle({ name, enabled, className }: { name: string; enabled: boolean; className?: string }) {
58 + const router = useRouter();
59 + const [pending, start] = useTransition();
60 + const [err, setErr] = useState<string | null>(null);
61 + return (
62 + <span className={cn('inline-flex flex-wrap items-center gap-2', className)}>
63 + <button
64 + type="button"
65 + role="switch"
66 + aria-checked={enabled}
67 + disabled={pending}
68 + className={cn(btn, enabled ? btnNeutral : btnAccent)}
69 + onClick={() =>
70 + start(async () => {
71 + setErr(null);
72 + const r = await post(`/api/admin/connectors/${encodeURIComponent(name)}/enabled`, { enabled: !enabled });
73 + if (r.ok) router.refresh();
74 + else setErr(r.error);
75 + })
76 + }
77 + >
78 + {pending ? '…' : enabled ? 'Disable' : 'Enable'}
79 + </button>
80 + {err && <span className="text-xs text-danger">{err}</span>}
81 + </span>
82 + );
83 +}
84 +
85 +const DECISION_LABELS: Record<ReviewDecision, string> = { merged: 'Merge B into A', kept_separate: 'Keep separate', dismissed: 'Dismiss' };
86 +
87 +export function ReviewDecisionButtons({ id, canMerge, className }: { id: number; canMerge: boolean; className?: string }) {
88 + const router = useRouter();
89 + const [pending, start] = useTransition();
90 + const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | null>(null);
91 + const [confirmMerge, setConfirmMerge] = useState(false);
92 + const decide = (decision: ReviewDecision) =>
93 + start(async () => {
94 + setMsg(null);
95 + const r = await post(`/api/admin/review/${id}`, { decision });
96 + if (r.ok) {
97 + setMsg({ tone: 'ok', text: `recorded: ${decision.replace('_', ' ')}` });
98 + setConfirmMerge(false);
99 + router.refresh();
100 + } else setMsg({ tone: 'err', text: r.error });
101 + });
102 + return (
103 + <div className={cn('flex flex-wrap items-center gap-2', className)}>
104 + {canMerge &&
105 + (confirmMerge ? (
106 + <>
107 + <button type="button" disabled={pending} className={cn(btn, btnDanger)} onClick={() => decide('merged')}>
108 + Confirm merge (audited)
109 + </button>
110 + <button type="button" disabled={pending} className={cn(btn, btnNeutral)} onClick={() => setConfirmMerge(false)}>
111 + Cancel
112 + </button>
113 + </>
114 + ) : (
115 + <button type="button" disabled={pending} className={cn(btn, btnAccent)} onClick={() => setConfirmMerge(true)}>
116 + {DECISION_LABELS.merged}
117 + </button>
118 + ))}
119 + <button type="button" disabled={pending} className={cn(btn, btnNeutral)} onClick={() => decide('kept_separate')}>
120 + {DECISION_LABELS.kept_separate}
121 + </button>
122 + <button type="button" disabled={pending} className={cn(btn, btnNeutral)} onClick={() => decide('dismissed')}>
123 + {DECISION_LABELS.dismissed}
124 + </button>
125 + {pending && <span className="text-xs text-ink-3">Saving…</span>}
126 + {msg && <span className={cn('text-xs', msg.tone === 'ok' ? 'text-active' : 'text-danger')}>{msg.text}</span>}
127 + </div>
128 + );
129 +}
added apps/web/src/components/admin/nav.tsx +43 −0
@@ -0,0 +1,43 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { cn } from '@/lib/cn';
6 +
7 +export const ADMIN_NAV = [
8 + { href: '/admin', label: 'Overview', exact: true },
9 + { href: '/admin/raw', label: 'Raw records' },
10 + { href: '/admin/data-quality', label: 'Data quality' },
11 + { href: '/admin/entity-resolution', label: 'Entity resolution' },
12 + { href: '/admin/costs', label: 'Costs' },
13 +] as const;
14 +
15 +/** Admin navigation: horizontal scrollable tabs on mobile, vertical sidebar from lg. Connector detail pages highlight Overview. */
16 +export function AdminNav({ className }: { className?: string }) {
17 + const pathname = usePathname() ?? '/admin';
18 + return (
19 + <nav aria-label="Admin" className={cn('no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 lg:mx-0 lg:flex-col lg:gap-0.5 lg:overflow-visible lg:px-0', className)}>
20 + {ADMIN_NAV.map((item) => {
21 + const active = 'exact' in item && item.exact ? pathname === item.href || pathname.startsWith('/admin/connectors') : pathname.startsWith(item.href);
22 + return (
23 + <Link
24 + key={item.href}
25 + href={item.href}
26 + aria-current={active ? 'page' : undefined}
27 + className={cn(
28 + 'inline-flex min-h-11 shrink-0 items-center rounded-md px-3 text-sm transition-colors lg:min-h-10',
29 + active ? 'bg-plane-3 text-ink' : 'text-ink-2 hover:bg-plane-2 hover:text-ink',
30 + )}
31 + >
32 + {item.label}
33 + </Link>
34 + );
35 + })}
36 + <form action="/api/admin/logout" method="post" className="ml-auto shrink-0 lg:ml-0 lg:mt-4 lg:border-t lg:border-rule lg:pt-3">
37 + <button type="submit" className="inline-flex min-h-11 items-center rounded-md px-3 text-sm text-ink-3 hover:bg-plane-2 hover:text-ink lg:min-h-10">
38 + Sign out
39 + </button>
40 + </form>
41 + </nav>
42 + );
43 +}
added apps/web/src/components/admin/types.ts +156 −0
@@ -0,0 +1,156 @@
1 +/** Admin API payload shapes (FastAPI `/api/v1/admin/*`). Plain types — safe to import from client components. */
2 +import type { Envelope, Meta, Num } from '@/lib/types';
3 +
4 +export interface AdminConnector {
5 + name: string;
6 + source_id: string;
7 + source_name: string;
8 + description: string | null;
9 + interval_seconds: number;
10 + enabled: boolean;
11 + priority: number;
12 + config: Record<string, unknown> | null;
13 + consecutive_failures: number;
14 + circuit_open_until: string | null;
15 + last_success_at: string | null;
16 + last_attempt_at: string | null;
17 + last_duration_ms: number | null;
18 + next_run_at: string | null;
19 + created_at: string;
20 + updated_at: string;
21 + last_status: string | null;
22 + errors_7d: Num;
23 +}
24 +
25 +export interface ConnectorRun {
26 + id: string;
27 + connector_name: string;
28 + source_id: string;
29 + started_at: string;
30 + finished_at: string | null;
31 + status: string;
32 + duration_ms: number | null;
33 + records_fetched: Num;
34 + records_created: Num;
35 + records_updated: Num;
36 + records_skipped: Num;
37 + error: string | null;
38 + payload_hash: string | null;
39 + meta: Record<string, unknown> | null;
40 +}
41 +
42 +export interface ConnectorError {
43 + id: number;
44 + connector_name: string;
45 + run_id: string | null;
46 + occurred_at: string;
47 + error_type: string | null;
48 + message: string | null;
49 + context: Record<string, unknown> | null;
50 +}
51 +
52 +export interface AdminOverview {
53 + connectors: AdminConnector[];
54 + recent_runs: ConnectorRun[];
55 + quality: { flag: string; open: Num }[];
56 + review_open: number;
57 + database: { db_bytes: Num; satellites: Num; elements: Num; raw: Num; raw_bytes: Num; events: Num; db_connections: Num };
58 + failed_jobs: ConnectorRun[];
59 + queue_depth: Num;
60 + redis: boolean;
61 +}
62 +
63 +export interface AdminPaginated<T> {
64 + data: T[];
65 + pagination: { page: number; page_size: number; total: number; pages: number };
66 + meta: Meta;
67 +}
68 +
69 +export interface ConnectorRunsPayload extends AdminPaginated<ConnectorRun> {
70 + errors: ConnectorError[];
71 +}
72 +
73 +export interface RawRecord {
74 + id: string;
75 + source_id: string;
76 + connector_name: string;
77 + run_id: string | null;
78 + source_native_id: string | null;
79 + content_type: string | null;
80 + payload_hash: string | null;
81 + byte_size: Num;
82 + storage_path: string | null;
83 + source_url: string | null;
84 + fetched_at: string;
85 + processed_at: string | null;
86 + processing_status: string | null;
87 + record_count: Num;
88 + error: string | null;
89 +}
90 +
91 +export interface RawRecordDetail extends RawRecord {
92 + preview: string | null;
93 + truncated: boolean;
94 +}
95 +
96 +export interface QualityFlagRow {
97 + id: number;
98 + entity_type: string;
99 + entity_id: string;
100 + flag: string;
101 + detail: string | null;
102 + created_at: string;
103 + resolved_at: string | null;
104 + slug: string | null;
105 + name: string | null;
106 + norad_id: number | null;
107 +}
108 +
109 +export interface DataQualityPayload extends AdminPaginated<QualityFlagRow> {
110 + summary: { flag: string; open: Num }[];
111 + checks: {
112 + missing_norad: Num;
113 + missing_cospar: Num;
114 + active_without_operator: Num;
115 + active_without_country: Num;
116 + stale_active: Num;
117 + broken_launch_links: Num;
118 + active_without_elements: Num;
119 + };
120 +}
121 +
122 +export interface ReviewItem {
123 + id: number;
124 + kind: string;
125 + entity_a_type: string;
126 + entity_a_id: string;
127 + entity_b_type: string | null;
128 + entity_b_id: string | null;
129 + confidence: number | null;
130 + detail: Record<string, unknown> | null;
131 + status: string;
132 + created_at: string;
133 + resolved_at: string | null;
134 + resolved_by: string | null;
135 + a_slug: string | null;
136 + a_name: string | null;
137 + a_norad: number | null;
138 + a_cospar: string | null;
139 + a_status: string | null;
140 + b_slug: string | null;
141 + b_name: string | null;
142 + b_norad: number | null;
143 + b_cospar: string | null;
144 + b_status: string | null;
145 +}
146 +
147 +export type ReviewDecision = 'merged' | 'kept_separate' | 'dismissed';
148 +
149 +export interface CostsPayload {
150 + per_connector: { connector_name: string; runs_30d: Num; records: Num; total_ms: Num; failed: Num }[];
151 + storage: { database: string; orbital_elements: string; satellites: string; raw_uncompressed: string };
152 + raw_growth: { day: string; snapshots: Num; bytes: Num }[];
153 + paid_providers: Record<string, string>;
154 +}
155 +
156 +export type AdminEnvelope<T> = Envelope<T>;
added apps/web/src/components/admin/ui.tsx +140 −0
@@ -0,0 +1,140 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { RunStatusPill, fmtDuration } from '@/components/meta/connectors-table';
4 +import { cn } from '@/lib/cn';
5 +import { fmtAgo, fmtDateTime, fmtInt, num } from '@/lib/format';
6 +import type { ConnectorRun } from './types';
7 +
8 +/** Server-safe admin building blocks: page header, stat tiles, error state, byte formatting, runs table. */
9 +
10 +export function fmtBytes(v: number | string | null | undefined): string {
11 + const n = num(v);
12 + if (n === null) return '—';
13 + if (n < 1024) return `${fmtInt(n)} B`;
14 + const units = ['KB', 'MB', 'GB', 'TB'];
15 + let x = n / 1024;
16 + let i = 0;
17 + while (x >= 1024 && i < units.length - 1) {
18 + x /= 1024;
19 + i++;
20 + }
21 + return `${x >= 100 ? x.toFixed(0) : x.toFixed(1)} ${units[i]}`;
22 +}
23 +
24 +export function AdminHeader({ title, lede, action }: { title: ReactNode; lede?: ReactNode; action?: ReactNode }) {
25 + return (
26 + <div className="mb-6 flex flex-wrap items-end justify-between gap-3 border-b border-rule pb-4">
27 + <div className="min-w-0">
28 + <p className="eyebrow">Admin</p>
29 + <h1 className="mt-1 text-2xl font-semibold tracking-tight md:text-3xl">{title}</h1>
30 + {lede && <p className="mt-1.5 max-w-2xl text-sm text-ink-2">{lede}</p>}
31 + </div>
32 + {action}
33 + </div>
34 + );
35 +}
36 +
37 +export function AdminError({ message, what = 'Admin API' }: { message: string; what?: string }) {
38 + return (
39 + <div role="alert" className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-ink">
40 + <p className="font-medium text-danger">{what} unavailable</p>
41 + <p className="mono mt-1 break-words text-xs text-ink-2">{message}</p>
42 + </div>
43 + );
44 +}
45 +
46 +export function Tile({ label, value, hint, tone, href }: { label: string; value: ReactNode; hint?: ReactNode; tone?: 'ok' | 'warn' | 'danger'; href?: string }) {
47 + const color = tone === 'ok' ? 'text-active' : tone === 'warn' ? 'text-warn' : tone === 'danger' ? 'text-danger' : '';
48 + const body = (
49 + <>
50 + <p className="eyebrow">{label}</p>
51 + <p className={cn('tnum mt-1 text-xl font-semibold tracking-tight md:text-2xl', color)}>{value}</p>
52 + {hint && <p className="mt-0.5 text-xs text-ink-3">{hint}</p>}
53 + </>
54 + );
55 + return href ? (
56 + <Link href={href} className="block min-w-0 rounded-md border border-rule px-3 py-2.5 hover:bg-plane-2">
57 + {body}
58 + </Link>
59 + ) : (
60 + <div className="min-w-0 rounded-md border border-rule px-3 py-2.5">{body}</div>
61 + );
62 +}
63 +
64 +export function SectionTitle({ children, action }: { children: ReactNode; action?: ReactNode }) {
65 + return (
66 + <div className="mb-3 mt-8 flex items-end justify-between gap-3">
67 + <h2 className="text-base font-semibold tracking-tight md:text-lg">{children}</h2>
68 + {action}
69 + </div>
70 + );
71 +}
72 +
73 +export function RunsTable({ runs, now = Date.now(), showConnector = true, emptyLabel = 'No runs recorded' }: { runs: ConnectorRun[]; now?: number; showConnector?: boolean; emptyLabel?: string }) {
74 + if (runs.length === 0) return <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">{emptyLabel}</p>;
75 + return (
76 + <div className="overflow-x-auto">
77 + <table className="data-table stack md:min-w-[860px]">
78 + <thead>
79 + <tr>
80 + {showConnector && <th>Connector</th>}
81 + <th>Started</th>
82 + <th>Status</th>
83 + <th className="num">Duration</th>
84 + <th className="num">Fetched</th>
85 + <th className="num">Created</th>
86 + <th className="num">Updated</th>
87 + <th>Error</th>
88 + </tr>
89 + </thead>
90 + <tbody>
91 + {runs.map((r) => (
92 + <tr key={r.id}>
93 + {showConnector && (
94 + <td className="primary">
95 + <Link href={`/admin/connectors/${encodeURIComponent(r.connector_name)}`} className="mono text-sm text-ink hover:text-accent">
96 + {r.connector_name}
97 + </Link>
98 + </td>
99 + )}
100 + <td data-label="Started" title={fmtDateTime(r.started_at)} className={cn(!showConnector && 'primary')}>
101 + <span className="text-sm">{fmtAgo(r.started_at, now)}</span>
102 + <span className="mono ml-2 text-xs text-ink-3">{fmtDateTime(r.started_at)}</span>
103 + </td>
104 + <td data-label="Status">
105 + <RunStatusPill status={r.status} />
106 + </td>
107 + <td data-label="Duration" className="num tnum text-sm text-ink-2">
108 + {fmtDuration(r.duration_ms)}
109 + </td>
110 + <td data-label="Fetched" className="num tnum text-sm">
111 + {fmtInt(r.records_fetched)}
112 + </td>
113 + <td data-label="Created" className="num tnum text-sm text-ink-2">
114 + {fmtInt(r.records_created)}
115 + </td>
116 + <td data-label="Updated" className="num tnum text-sm text-ink-2">
117 + {fmtInt(r.records_updated)}
118 + </td>
119 + <td data-label="Error" className="max-w-[320px]">
120 + {r.error ? (
121 + <span className="mono block truncate text-xs text-danger" title={r.error}>
122 + {r.error}
123 + </span>
124 + ) : (
125 + <span className="text-xs text-ink-3">—</span>
126 + )}
127 + </td>
128 + </tr>
129 + ))}
130 + </tbody>
131 + </table>
132 + </div>
133 + );
134 +}
135 +
136 +/** Small mono JSON preview (config, run meta) — scrolls instead of overflowing. */
137 +export function JsonInline({ value, className }: { value: unknown; className?: string }) {
138 + if (value === null || value === undefined) return <span className="text-xs text-ink-3">—</span>;
139 + return <code className={cn('mono block max-w-full overflow-x-auto whitespace-pre text-[11px] text-ink-3', className)}>{JSON.stringify(value)}</code>;
140 +}
modified apps/web/src/components/charts/charts.tsx +1 −1
@@ -36,7 +36,7 @@ export function HBars({ data, className, max, valueFormat = fmtInt, barHeight =
36 36 <li key={`${d.label}-${i}`} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm" style={{ minHeight: barHeight }}>
37 37 <div className="min-w-0">
38 38 <div className="flex items-baseline justify-between gap-2">
39 − <span className="truncate text-ink">{d.label}</span>
39 + {d.href ? <a href={d.href} className="truncate text-ink hover:text-accent hover:underline">{d.label}</a> : <span className="truncate text-ink">{d.label}</span>}
40 40 {showValue && <span className="tnum shrink-0 text-xs text-ink-2">{valueFormat(d.value)}</span>}
41 41 </div>
42 42 <div className="mt-1 h-[6px] w-full overflow-hidden rounded-full bg-plane-2">
added apps/web/src/components/events/event-badge.tsx +98 −0
@@ -0,0 +1,98 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/cn';
3 +import { EVENT_TYPE_LABELS, routes } from '@/lib/site';
4 +import type { EventEntity } from '@/lib/types';
5 +
6 +/** Event type → colour token. Launch = accent, decay/reentry = warn, orbit change = accent-2, decommission = danger, activation = active. */
7 +export const EVENT_TYPE_COLORS: Record<string, string> = {
8 + SATELLITE_LAUNCH: 'var(--accent)',
9 + DECAY: 'var(--warn)',
10 + REENTRY: 'var(--warn)',
11 + ORBIT_CHANGE: 'var(--accent-2)',
12 + SATELLITE_DECOMMISSION: 'var(--danger)',
13 + SATELLITE_ACTIVATION: 'var(--active)',
14 + CONSTELLATION_EXPANSION: 'var(--accent)',
15 + REGULATORY_APPROVAL: 'var(--series-4)',
16 + NEW_LICENSE: 'var(--series-4)',
17 + COMPANY_ANNOUNCEMENT: 'var(--inactive)',
18 +};
19 +
20 +export function eventColor(type: string): string {
21 + return EVENT_TYPE_COLORS[type] ?? 'var(--inactive)';
22 +}
23 +export function eventLabel(type: string): string {
24 + return EVENT_TYPE_LABELS[type] ?? type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
25 +}
26 +
27 +export function EventTypeBadge({ type, className, size = 'sm' }: { type: string; className?: string; size?: 'sm' | 'md' }) {
28 + const color = eventColor(type);
29 + return (
30 + <span className={cn('inline-flex items-center gap-1.5 whitespace-nowrap rounded border font-medium', size === 'sm' ? 'px-1.5 py-0.5 text-[11px]' : 'px-2.5 py-1 text-xs', className)} style={{ color, borderColor: `color-mix(in oklab, ${color} 35%, transparent)`, background: `color-mix(in oklab, ${color} 10%, transparent)` }}>
31 + <span className="dot" aria-hidden />
32 + {eventLabel(type)}
33 + </span>
34 + );
35 +}
36 +
37 +/** Confidence as a mono percentage; tooltip explains it is derived. */
38 +export function Confidence({ value, className }: { value: number | null | undefined; className?: string }) {
39 + if (value === null || value === undefined || !Number.isFinite(value)) return <span className={cn('mono text-xs text-ink-3', className)}>—</span>;
40 + const pct = Math.round(value * 100);
41 + const color = pct >= 90 ? 'var(--active)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)';
42 + return (
43 + <span className={cn('mono text-xs', className)} title="Derived confidence: how sure SatelliteIndex is about this event given its source and dedupe rules" style={{ color }}>
44 + {pct}%
45 + </span>
46 + );
47 +}
48 +
49 +export function entityHref(e: EventEntity): string | null {
50 + if (!e.slug) return null;
51 + switch (e.type) {
52 + case 'satellite':
53 + return routes.satellite(e.slug);
54 + case 'launch':
55 + return routes.launch(e.slug);
56 + case 'constellation':
57 + return routes.constellation(e.slug);
58 + case 'operator':
59 + return routes.operator(e.slug);
60 + case 'country':
61 + return routes.country(e.slug);
62 + case 'launch_site':
63 + return routes.launchSite(e.slug);
64 + default:
65 + return null;
66 + }
67 +}
68 +
69 +export function entityName(e: EventEntity): string {
70 + if (e.type === 'launch') return `Launch ${e.slug ?? e.id}`;
71 + return e.name ?? e.slug ?? e.id;
72 +}
73 +
74 +/** Inline list of entity links: satellite → /satellite/<slug>, launch → /launch/<cospar>. */
75 +export function EntityLinks({ entities, className, max = 6 }: { entities: EventEntity[] | null | undefined; className?: string; max?: number }) {
76 + if (!entities?.length) return null;
77 + const shown = entities.slice(0, max);
78 + return (
79 + <ul className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-xs', className)}>
80 + {shown.map((e) => {
81 + const href = entityHref(e);
82 + const label = entityName(e);
83 + return (
84 + <li key={`${e.type}-${e.id}`} className="inline-flex items-center gap-1">
85 + <span className="mono text-[10px] uppercase tracking-wider text-ink-3">{e.type.replace('_', ' ')}</span>
86 + {href ? (
87 + <Link href={href} className="link">{label}</Link>
88 + ) : (
89 + <span className="text-ink-2">{label}</span>
90 + )}
91 + {e.norad_id ? <span className="mono text-ink-3">#{e.norad_id}</span> : null}
92 + </li>
93 + );
94 + })}
95 + {entities.length > max && <li className="text-ink-3">+{entities.length - max} more</li>}
96 + </ul>
97 + );
98 +}
added apps/web/src/components/events/event-timeline.tsx +90 −0
@@ -0,0 +1,90 @@
1 +import Link from 'next/link';
2 +import { fmtDate, fmtDateTime } from '@/lib/format';
3 +import type { EventRow } from '@/lib/types';
4 +import { Confidence, EntityLinks, EventTypeBadge, eventColor } from './event-badge';
5 +
6 +function dayKey(iso: string): string {
7 + return iso.slice(0, 10);
8 +}
9 +function dayHeading(key: string, today: string): string {
10 + if (key === today) return `Today · ${fmtDate(key)}`;
11 + const y = new Date(`${today}T00:00:00Z`);
12 + y.setUTCDate(y.getUTCDate() - 1);
13 + if (key === y.toISOString().slice(0, 10)) return `Yesterday · ${fmtDate(key)}`;
14 + return fmtDate(key);
15 +}
16 +function timeOnly(iso: string): string {
17 + const d = new Date(iso);
18 + if (Number.isNaN(d.getTime())) return '—';
19 + return `${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC`;
20 +}
21 +
22 +/** One event row: time · type · title/summary/entities · source + confidence. Desktop = 4-column terminal grid; mobile = stacked. */
23 +export function EventItem({ e, dense = false }: { e: EventRow; dense?: boolean }) {
24 + const href = `/events/${encodeURIComponent(e.id)}`;
25 + return (
26 + <li className="relative border-b border-rule py-3 md:grid md:grid-cols-[92px_150px_minmax(0,1fr)_180px] md:items-start md:gap-4 md:py-3.5">
27 + <span className="absolute -left-4 top-[18px] hidden size-1.5 rounded-full md:block" style={{ background: eventColor(e.type) }} aria-hidden />
28 + <div className="flex items-center justify-between gap-2 md:block">
29 + <time dateTime={e.event_time} className="mono text-xs text-ink-2" title={fmtDateTime(e.event_time)}>
30 + {timeOnly(e.event_time)}
31 + </time>
32 + <div className="md:hidden">
33 + <EventTypeBadge type={e.type} />
34 + </div>
35 + </div>
36 + <div className="hidden md:block">
37 + <EventTypeBadge type={e.type} />
38 + </div>
39 + <div className="mt-1.5 min-w-0 md:mt-0">
40 + <Link href={href} className="link text-sm font-medium leading-snug">
41 + {e.title}
42 + </Link>
43 + {e.summary && !dense && <p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-ink-3">{e.summary}</p>}
44 + <EntityLinks entities={e.entities} className="mt-1.5" />
45 + </div>
46 + <div className="mt-2 flex items-center justify-between gap-3 text-xs md:mt-0 md:block md:text-right">
47 + <span className="truncate text-ink-3" title={e.source_id ?? undefined}>
48 + {e.source_url ? (
49 + <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">{e.source_name ?? e.source_id ?? 'Source'}</a>
50 + ) : (
51 + e.source_name ?? e.source_id ?? 'Source unavailable'
52 + )}
53 + </span>
54 + <span className="md:mt-0.5 md:block">
55 + <Confidence value={e.confidence} />
56 + </span>
57 + </div>
58 + </li>
59 + );
60 +}
61 +
62 +/** Events grouped visually by UTC day. */
63 +export function EventTimeline({ events, dense = false }: { events: EventRow[]; dense?: boolean }) {
64 + const today = new Date().toISOString().slice(0, 10);
65 + const groups: { day: string; items: EventRow[] }[] = [];
66 + for (const e of events) {
67 + const k = dayKey(e.event_time);
68 + const last = groups[groups.length - 1];
69 + if (last && last.day === k) last.items.push(e);
70 + else groups.push({ day: k, items: [e] });
71 + }
72 + return (
73 + <div className="space-y-8">
74 + {groups.map((g) => (
75 + <section key={g.day} aria-label={fmtDate(g.day)}>
76 + <div className="mb-1 flex items-center gap-3">
77 + <h3 className="mono text-xs font-semibold uppercase tracking-wider text-ink-2">{dayHeading(g.day, today)}</h3>
78 + <span className="h-px flex-1 bg-rule" aria-hidden />
79 + <span className="mono text-[11px] text-ink-3">{g.items.length} event{g.items.length > 1 ? 's' : ''}</span>
80 + </div>
81 + <ol className="md:ml-4 md:border-l md:border-rule md:pl-4">
82 + {g.items.map((e) => (
83 + <EventItem key={e.id} e={e} dense={dense} />
84 + ))}
85 + </ol>
86 + </section>
87 + ))}
88 + </div>
89 + );
90 +}
modified apps/web/src/components/globe/earth.tsx +1 −1
@@ -124,7 +124,7 @@ export function Earth({ quality = 'high' }: { quality?: 'high' | 'low' }) {
124 124 const borders = topoMesh(cTopo, cTopo.objects.countries as never, ((a: { id: string }, b: { id: string }) => a !== b) as never) as unknown as MultiLine;
125 125 const accent = new THREE.Color(token('--accent'));
126 126 const night = new THREE.Color(token('--plane')).multiplyScalar(0.55);
127 − const day = new THREE.Color(token('--plane-2')).lerp(new THREE.Color(token('--plane-3')), 0.5);
127 + const day = new THREE.Color(token('--plane-3')).lerp(new THREE.Color(token('--accent')), 0.05);
128 128 const rim = new THREE.Color(token('--accent')).multiplyScalar(0.6);
129 129 return {
130 130 landGeo: lineGeometry(linesToSegments(land, R_LAND)),
added apps/web/src/components/globe/focus-search.tsx +130 −0
@@ -0,0 +1,130 @@
1 +'use client';
2 +/** Search-to-focus: type a name / NORAD / COSPAR, pick a satellite, the globe flies to its live position. */
3 +import { Loader2, Search, X } from 'lucide-react';
4 +import { useEffect, useRef, useState } from 'react';
5 +import { clientApi } from '@/lib/client-api';
6 +import { cn } from '@/lib/cn';
7 +import type { SearchResult } from '@/lib/types';
8 +
9 +export interface FocusTarget {
10 + norad: number;
11 + name: string;
12 + slug: string;
13 +}
14 +
15 +function noradFrom(r: SearchResult): number | null {
16 + const m = /NORAD\s+(\d+)/i.exec(r.subtitle ?? '');
17 + if (m) return Number(m[1]);
18 + const tail = /-(\d+)$/.exec(r.slug);
19 + return tail ? Number(tail[1]) : null;
20 +}
21 +
22 +export function FocusSearch({ onPick, current, onClear, className }: { onPick: (t: FocusTarget) => void; current: FocusTarget | null; onClear: () => void; className?: string }) {
23 + const [q, setQ] = useState('');
24 + const [open, setOpen] = useState(false);
25 + const [loading, setLoading] = useState(false);
26 + const [items, setItems] = useState<{ r: SearchResult; norad: number }[]>([]);
27 + const [active, setActive] = useState(0);
28 + const boxRef = useRef<HTMLDivElement>(null);
29 +
30 + useEffect(() => {
31 + const term = q.trim();
32 + if (term.length < 1) {
33 + setItems([]);
34 + return;
35 + }
36 + const ctrl = new AbortController();
37 + const t = setTimeout(async () => {
38 + setLoading(true);
39 + try {
40 + const res = await clientApi.search(term, 12, ctrl.signal);
41 + const sats = res.data.results
42 + .filter((r) => r.entity_type === 'satellite')
43 + .map((r) => ({ r, norad: noradFrom(r) }))
44 + .filter((x): x is { r: SearchResult; norad: number } => x.norad !== null);
45 + setItems(sats);
46 + setActive(0);
47 + setOpen(true);
48 + } catch {
49 + /* aborted */
50 + } finally {
51 + setLoading(false);
52 + }
53 + }, 160);
54 + return () => {
55 + clearTimeout(t);
56 + ctrl.abort();
57 + };
58 + }, [q]);
59 +
60 + useEffect(() => {
61 + const onDoc = (e: PointerEvent) => {
62 + if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
63 + };
64 + document.addEventListener('pointerdown', onDoc);
65 + return () => document.removeEventListener('pointerdown', onDoc);
66 + }, []);
67 +
68 + const pick = (it: { r: SearchResult; norad: number }) => {
69 + onPick({ norad: it.norad, name: it.r.title, slug: it.r.slug });
70 + setQ('');
71 + setItems([]);
72 + setOpen(false);
73 + };
74 +
75 + return (
76 + <div ref={boxRef} className={cn('relative', className)}>
77 + <div className="flex h-11 items-center gap-2 rounded-md border border-rule bg-plane/85 px-3 backdrop-blur-md focus-within:border-rule-strong">
78 + {loading ? <Loader2 className="size-4 animate-spin text-ink-3" aria-hidden /> : <Search className="size-4 text-ink-3" aria-hidden />}
79 + {current && !q ? (
80 + <div className="flex min-w-0 flex-1 items-center gap-2">
81 + <span className="truncate text-sm text-ink">{current.name}</span>
82 + <span className="mono shrink-0 text-2xs text-ink-3">{current.norad}</span>
83 + <button type="button" onClick={onClear} className="ml-auto flex size-8 shrink-0 items-center justify-center rounded text-ink-3 hover:text-ink" aria-label="Clear focus">
84 + <X className="size-4" aria-hidden />
85 + </button>
86 + </div>
87 + ) : (
88 + <input
89 + value={q}
90 + onChange={(e) => setQ(e.target.value)}
91 + onFocus={() => items.length && setOpen(true)}
92 + onKeyDown={(e) => {
93 + if (e.key === 'ArrowDown') {
94 + e.preventDefault();
95 + setActive((a) => Math.min(a + 1, items.length - 1));
96 + } else if (e.key === 'ArrowUp') {
97 + e.preventDefault();
98 + setActive((a) => Math.max(a - 1, 0));
99 + } else if (e.key === 'Enter') {
100 + const it = items[active];
101 + if (it) pick(it);
102 + } else if (e.key === 'Escape') setOpen(false);
103 + }}
104 + placeholder="Focus a satellite: name, NORAD, COSPAR…"
105 + className="min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none md:text-sm"
106 + aria-label="Search a satellite to focus"
107 + autoComplete="off"
108 + spellCheck={false}
109 + />
110 + )}
111 + </div>
112 + {open && q.trim() && (
113 + <ul className="panel absolute left-0 right-0 top-full z-20 mt-1 max-h-[46vh] overflow-y-auto py-1" role="listbox">
114 + {items.length === 0 && !loading && <li className="px-3 py-3 text-sm text-ink-3">No tracked satellite matches “{q.trim()}”.</li>}
115 + {items.map((it, i) => (
116 + <li key={it.r.entity_id} role="option" aria-selected={i === active}>
117 + <button type="button" onMouseEnter={() => setActive(i)} onClick={() => pick(it)} className={cn('flex min-h-[44px] w-full items-center gap-3 px-3 py-2 text-left', i === active ? 'bg-plane-3' : 'hover:bg-plane-2')}>
118 + <span className="min-w-0 flex-1">
119 + <span className="block truncate text-sm text-ink">{it.r.title}</span>
120 + <span className="block truncate text-2xs text-ink-3">{it.r.subtitle}</span>
121 + </span>
122 + <span className="mono shrink-0 text-2xs text-ink-3">{it.norad}</span>
123 + </button>
124 + </li>
125 + ))}
126 + </ul>
127 + )}
128 + </div>
129 + );
130 +}
added apps/web/src/components/globe/globe-fallback.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/cn';
3 +import { routes } from '@/lib/site';
4 +
5 +/** Immediate shell while the 3D bundle loads: a dark disc with the accent limb so the layout never jumps. */
6 +export function GlobeSkeleton({ className, label = 'Loading globe…' }: { className?: string; label?: string }) {
7 + return (
8 + <div className={cn('relative flex h-full w-full items-center justify-center overflow-hidden', className)} aria-busy="true" aria-live="polite">
9 + <div
10 + className="aspect-square w-[min(72%,520px)] rounded-full"
11 + style={{
12 + background: 'radial-gradient(circle at 35% 30%, rgba(24,33,64,0.9), rgba(11,16,32,0.95) 55%, rgba(6,9,18,1) 72%)',
13 + boxShadow: '0 0 0 1px rgba(56,211,255,0.10), 0 0 60px rgba(56,211,255,0.10), inset -30px -20px 80px rgba(0,0,0,0.5)',
14 + }}
15 + />
16 + <p className="mono absolute bottom-4 left-4 text-2xs uppercase tracking-[0.14em] text-ink-3">{label}</p>
17 + </div>
18 + );
19 +}
20 +
21 +/** Honest fallback when WebGL is unavailable (headless, blocked GPU, old device). */
22 +export function GlobeUnavailable({ className, reason }: { className?: string; reason?: string }) {
23 + return (
24 + <div className={cn('flex h-full w-full items-center justify-center p-6', className)} role="status">
25 + <div className="max-w-sm text-center">
26 + <p className="eyebrow">3D globe</p>
27 + <p className="mt-2 text-base font-medium text-ink">WebGL is not available in this browser</p>
28 + <p className="mt-2 text-sm leading-relaxed text-ink-3">{reason ?? 'The live globe needs hardware-accelerated graphics. Positions and orbital data are still available in the catalogue.'}</p>
29 + <Link href={routes.satellites('has_gp=true')} className="mt-4 inline-flex h-11 items-center justify-center rounded-md border border-rule-strong px-4 text-sm text-ink hover:bg-plane-2">
30 + Browse tracked satellites →
31 + </Link>
32 + </div>
33 + </div>
34 + );
35 +}
added apps/web/src/components/globe/globe-filters.tsx +73 −0
@@ -0,0 +1,73 @@
1 +'use client';
2 +import { cn } from '@/lib/cn';
3 +import { fmtInt } from '@/lib/format';
4 +import { MISSION_LABELS, ORBIT_CLASS_COLORS } from '@/lib/site';
5 +import { MISSIONS, ORBIT_CLASSES, toggleIn, type GlobeFilters } from './filters';
6 +import type { GlobeData } from './use-positions';
7 +
8 +function Chip({ on, onClick, children, color, title }: { on: boolean; onClick: () => void; children: React.ReactNode; color?: string; title?: string }) {
9 + return (
10 + <button
11 + type="button"
12 + onClick={onClick}
13 + aria-pressed={on}
14 + title={title}
15 + className={cn('inline-flex min-h-[36px] items-center gap-1.5 rounded-full border px-2.5 text-xs transition-colors', on ? 'border-rule-strong bg-plane-3 text-ink' : 'border-rule bg-plane/60 text-ink-2 hover:border-rule-strong hover:text-ink')}
16 + >
17 + {color && <span className="inline-block size-2 rounded-full" style={{ background: color }} aria-hidden />}
18 + {children}
19 + </button>
20 + );
21 +}
22 +
23 +/** Filter chips: orbit class, mission type, active-only. Counts come from the live snapshot (whole set, not the capped subset). */
24 +export function GlobeFilterChips({ data, filters, onChange, className }: { data: GlobeData | null; filters: GlobeFilters; onChange: (f: GlobeFilters) => void; className?: string }) {
25 + const clsCount = (c: string) => {
26 + const i = data?.legend.cls.indexOf(c) ?? -1;
27 + return i >= 0 ? data?.counts.cls[i] ?? null : null;
28 + };
29 + const misCount = (m: string) => {
30 + const i = data?.legend.mission.indexOf(m) ?? -1;
31 + return i >= 0 ? data?.counts.mission[i] ?? null : null;
32 + };
33 + const anyActive = filters.cls.size + filters.mission.size > 0 || filters.activeOnly;
34 + return (
35 + <div className={cn('space-y-3', className)}>
36 + <div>
37 + <p className="eyebrow mb-1.5">Orbit class</p>
38 + <div className="flex flex-wrap gap-1.5">
39 + {ORBIT_CLASSES.map((c) => (
40 + <Chip key={c} on={filters.cls.has(c)} onClick={() => onChange({ ...filters, cls: toggleIn(filters.cls, c) })} color={ORBIT_CLASS_COLORS[c]} title={`${c} objects with current element sets`}>
41 + <span className="mono">{c}</span>
42 + <span className="tnum text-ink-3">{clsCount(c) === null ? '—' : fmtInt(clsCount(c))}</span>
43 + </Chip>
44 + ))}
45 + </div>
46 + </div>
47 + <div>
48 + <p className="eyebrow mb-1.5">
49 + Mission type <span className="normal-case tracking-normal text-ink-3">(derived)</span>
50 + </p>
51 + <div className="flex flex-wrap gap-1.5">
52 + {MISSIONS.map((m) => (
53 + <Chip key={m} on={filters.mission.has(m)} onClick={() => onChange({ ...filters, mission: toggleIn(filters.mission, m) })}>
54 + {MISSION_LABELS[m] ?? m}
55 + <span className="tnum text-ink-3">{misCount(m) === null ? '—' : fmtInt(misCount(m))}</span>
56 + </Chip>
57 + ))}
58 + </div>
59 + </div>
60 + <div className="flex flex-wrap items-center gap-1.5">
61 + <Chip on={filters.activeOnly} onClick={() => onChange({ ...filters, activeOnly: !filters.activeOnly })} color="var(--active)">
62 + Active only
63 + <span className="tnum text-ink-3">{data ? fmtInt(data.counts.active) : '—'}</span>
64 + </Chip>
65 + {anyActive && (
66 + <button type="button" onClick={() => onChange({ cls: new Set(), mission: new Set(), activeOnly: false })} className="min-h-[36px] px-2 text-xs text-accent hover:underline">
67 + Reset
68 + </button>
69 + )}
70 + </div>
71 + </div>
72 + );
73 +}
added apps/web/src/components/globe/globe-legend.tsx +35 −0
@@ -0,0 +1,35 @@
1 +'use client';
2 +import { cn } from '@/lib/cn';
3 +import { fmtInt } from '@/lib/format';
4 +import { ORBIT_CLASS_COLORS } from '@/lib/site';
5 +import { ORBIT_CLASSES, type GlobeFilters } from './filters';
6 +import type { GlobeData } from './use-positions';
7 +
8 +/** Orbit-class legend with live counts; each entry is a toggle (tap target ≥ 44 px on touch via padding). */
9 +export function GlobeLegend({ data, filters, onToggle, className, dense = false }: { data: GlobeData | null; filters: GlobeFilters; onToggle: (cls: string) => void; className?: string; dense?: boolean }) {
10 + return (
11 + <ul className={cn('flex flex-wrap items-center gap-1', className)} aria-label="Orbit classes">
12 + {ORBIT_CLASSES.map((c) => {
13 + const idx = data?.legend.cls.indexOf(c) ?? -1;
14 + const count = idx >= 0 ? data?.counts.cls[idx] ?? null : null;
15 + const on = filters.cls.size === 0 || filters.cls.has(c);
16 + const color = ORBIT_CLASS_COLORS[c] ?? 'var(--other)';
17 + return (
18 + <li key={c}>
19 + <button
20 + type="button"
21 + onClick={() => onToggle(c)}
22 + aria-pressed={filters.cls.has(c)}
23 + className={cn('mono inline-flex min-h-[36px] items-center gap-1.5 rounded-md border px-2 text-[11px] transition-colors', on ? 'border-rule bg-plane/70 text-ink' : 'border-transparent text-ink-3 opacity-60', dense ? 'min-h-[32px]' : '')}
24 + title={`${c}: ${count === null ? 'count unavailable' : `${fmtInt(count)} objects`} — click to isolate`}
25 + >
26 + <span className="inline-block size-2 rounded-full" style={{ background: color, boxShadow: on ? `0 0 8px ${color}` : 'none' }} aria-hidden />
27 + {c}
28 + <span className="tnum text-ink-3">{count === null ? '—' : fmtInt(count)}</span>
29 + </button>
30 + </li>
31 + );
32 + })}
33 + </ul>
34 + );
35 +}
added apps/web/src/components/globe/globe-scene.tsx +205 −0
@@ -0,0 +1,205 @@
1 +'use client';
2 +/**
3 + * The R3F scene: camera + controls (auto-rotate until first interaction, damping, touch), Earth, the satellite
4 + * point cloud, the optional ground/orbit track of the focused object and the camera "fly-to" rig.
5 + */
6 +import { OrbitControls } from '@react-three/drei';
7 +import { Canvas, useFrame, useThree } from '@react-three/fiber';
8 +import { useEffect, useMemo, useRef, useState, type ComponentRef, type MutableRefObject } from 'react';
9 +import * as THREE from 'three';
10 +
11 +type OrbitControlsImpl = ComponentRef<typeof OrbitControls>;
12 +import { clientApi } from '@/lib/client-api';
13 +import type { Track } from '@/lib/types';
14 +import { Earth } from './earth';
15 +import { altToRadius, isLowPower, llaToXyz, prefersReducedMotion, token } from './geo';
16 +import { SatellitePoints, type PickResult } from './satellite-points';
17 +import type { GlobeData } from './use-positions';
18 +
19 +export interface SceneProps {
20 + data: GlobeData | null;
21 + flags: Float32Array;
22 + flagsVersion: number;
23 + highlight: number | null;
24 + /** Bumped to request a fly-to on the highlighted object. */
25 + focusRequest: number;
26 + focusNorad: number | null;
27 + onPick: (r: PickResult | null) => void;
28 + variant: 'hero' | 'full';
29 + onInteract?: () => void;
30 +}
31 +
32 +function CameraRig({ highlightPos, focusRequest, controls, variant }: { highlightPos: MutableRefObject<THREE.Vector3>; focusRequest: number; controls: MutableRefObject<OrbitControlsImpl | null>; variant: 'hero' | 'full' }) {
33 + const { camera } = useThree();
34 + const flying = useRef(false);
35 + const start = useRef(0);
36 + const from = useRef(new THREE.Vector3());
37 + const reduced = prefersReducedMotion();
38 + const lastReq = useRef(0);
39 + const pending = useRef(false);
40 +
41 + useFrame(() => {
42 + if (focusRequest !== lastReq.current && focusRequest > 0) {
43 + lastReq.current = focusRequest;
44 + pending.current = true;
45 + if (controls.current) controls.current.autoRotate = false;
46 + }
47 + // The point cloud writes `highlightPos` in its own frame callback; start the flight once it is available.
48 + if (pending.current && highlightPos.current.lengthSq() > 0.5) {
49 + pending.current = false;
50 + flying.current = true;
51 + start.current = performance.now();
52 + from.current.copy(camera.position);
53 + }
54 + if (!flying.current) return;
55 + const dist = Math.max(variant === 'hero' ? 2.6 : 2.2, Math.min(from.current.length(), 3.6));
56 + const target = highlightPos.current.clone().normalize().multiplyScalar(dist);
57 + const dur = reduced ? 1 : 1100;
58 + const t = Math.min(1, (performance.now() - start.current) / dur);
59 + const k = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; // easeInOutCubic
60 + // Spherical interpolation keeps the camera on the sphere of radius `dist` (no dive through the globe).
61 + const a = from.current.clone().normalize();
62 + const b = target.clone().normalize();
63 + const ang = a.angleTo(b);
64 + let dir: THREE.Vector3;
65 + if (ang < 1e-4) dir = b;
66 + else {
67 + const q = new THREE.Quaternion().setFromUnitVectors(a, b);
68 + const qk = new THREE.Quaternion().slerp(q, k);
69 + dir = a.clone().applyQuaternion(qk);
70 + }
71 + const r = THREE.MathUtils.lerp(from.current.length(), dist, k);
72 + camera.position.copy(dir.multiplyScalar(r));
73 + camera.lookAt(0, 0, 0);
74 + controls.current?.update();
75 + if (t >= 1) flying.current = false;
76 + });
77 + return null;
78 +}
79 +
80 +function TrackLine({ norad, color }: { norad: number | null; color: THREE.Color }) {
81 + const [track, setTrack] = useState<Track | null>(null);
82 + useEffect(() => {
83 + setTrack(null);
84 + if (norad === null) return;
85 + const ctrl = new AbortController();
86 + clientApi
87 + .track(String(norad), ctrl.signal)
88 + .then((r) => setTrack(r.data))
89 + .catch(() => undefined);
90 + return () => ctrl.abort();
91 + }, [norad]);
92 +
93 + const objects = useMemo(() => {
94 + if (!track || track.points.length < 2) return null;
95 + const build = (pts: Track['points'], opacity: number) => {
96 + const arr = new Float32Array(pts.length * 3);
97 + pts.forEach((p, i) => llaToXyz(p.lat, p.lon, altToRadius(p.alt), arr, i * 3));
98 + const g = new THREE.BufferGeometry();
99 + g.setAttribute('position', new THREE.BufferAttribute(arr, 3));
100 + const m = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
101 + const line = new THREE.Line(g, m);
102 + line.renderOrder = 4;
103 + return line;
104 + };
105 + const past = track.points.filter((p) => !p.future);
106 + const futureStart = Math.max(0, past.length - 1);
107 + const future = track.points.slice(futureStart);
108 + return { past: past.length > 1 ? build(past, 0.35) : null, future: future.length > 1 ? build(future, 0.85) : null };
109 + }, [track, color]);
110 +
111 + useEffect(
112 + () => () => {
113 + objects?.past?.geometry.dispose();
114 + objects?.future?.geometry.dispose();
115 + },
116 + [objects],
117 + );
118 +
119 + if (!objects) return null;
120 + return (
121 + <group>
122 + {objects.past && <primitive object={objects.past} />}
123 + {objects.future && <primitive object={objects.future} />}
124 + </group>
125 + );
126 +}
127 +
128 +/** Frame the globe for the viewport aspect until the user takes over: portrait phones fit the LEO shell horizontally. */
129 +function FitCamera({ variant, interacted }: { variant: 'hero' | 'full'; interacted: boolean }) {
130 + const { camera, size } = useThree();
131 + useEffect(() => {
132 + if (interacted || !(camera instanceof THREE.PerspectiveCamera)) return;
133 + const aspect = size.width / Math.max(1, size.height);
134 + const vHalf = (camera.fov / 2) * (Math.PI / 180);
135 + const hHalf = Math.atan(Math.tan(vHalf) * aspect);
136 + const half = Math.min(vHalf, hHalf);
137 + const radius = aspect < 0.9 ? 1.32 : variant === 'hero' ? 1.22 : 1.12;
138 + const dist = Math.min(6, Math.max(1.8, radius / Math.sin(half)));
139 + camera.position.setLength(dist);
140 + camera.updateProjectionMatrix();
141 + }, [camera, size, variant, interacted]);
142 + return null;
143 +}
144 +
145 +function TouchAction({ mode }: { mode: string }) {
146 + const { gl } = useThree();
147 + useEffect(() => {
148 + // OrbitControls forces `touch-action: none`; on the homepage hero we keep vertical page scrolling.
149 + const el = gl.domElement;
150 + const t = setTimeout(() => {
151 + el.style.touchAction = mode;
152 + }, 0);
153 + return () => clearTimeout(t);
154 + }, [gl, mode]);
155 + return null;
156 +}
157 +
158 +export function GlobeScene({ data, flags, flagsVersion, highlight, focusRequest, focusNorad, onPick, variant, onInteract }: SceneProps) {
159 + const controls = useRef<OrbitControlsImpl | null>(null);
160 + const highlightPos = useRef(new THREE.Vector3());
161 + const reduced = prefersReducedMotion();
162 + const low = isLowPower();
163 + const trackColor = useMemo(() => new THREE.Color(token('--accent')), []);
164 + const [interacted, setInteracted] = useState(false);
165 +
166 + const camPos: [number, number, number] = variant === 'hero' ? [1.6, 0.9, 2.6] : [1.4, 0.8, 2.6];
167 +
168 + return (
169 + <Canvas
170 + dpr={low ? [1, 1.5] : [1, 2]}
171 + camera={{ position: camPos, fov: variant === 'hero' ? 38 : 42, near: 0.05, far: 50 }}
172 + gl={{ antialias: !low, alpha: true, powerPreference: low ? 'low-power' : 'high-performance' }}
173 + style={{ background: 'transparent', touchAction: variant === 'hero' ? 'pan-y' : 'none' }}
174 + frameloop="always"
175 + onPointerMissed={() => undefined}
176 + aria-label="3D globe of tracked satellites"
177 + role="img"
178 + >
179 + <TouchAction mode={variant === 'hero' ? 'pan-y' : 'none'} />
180 + <FitCamera variant={variant} interacted={interacted} />
181 + <Earth quality={low ? 'low' : 'high'} />
182 + {data && <SatellitePoints data={data} flags={flags} flagsVersion={flagsVersion} highlight={highlight} highlightPos={highlightPos} onPick={onPick} pointScale={variant === 'hero' ? 0.9 : 1} />}
183 + {variant === 'full' && <TrackLine norad={focusNorad} color={trackColor} />}
184 + <CameraRig highlightPos={highlightPos} focusRequest={focusRequest} controls={controls} variant={variant} />
185 + <OrbitControls
186 + ref={controls}
187 + enablePan={false}
188 + enableZoom={variant === 'full'}
189 + minDistance={1.35}
190 + maxDistance={6}
191 + enableDamping
192 + dampingFactor={0.08}
193 + rotateSpeed={0.55}
194 + zoomSpeed={0.7}
195 + autoRotate={!reduced && !interacted}
196 + autoRotateSpeed={variant === 'hero' ? 0.45 : 0.3}
197 + makeDefault
198 + onStart={() => {
199 + if (!interacted) setInteracted(true);
200 + onInteract?.();
201 + }}
202 + />
203 + </Canvas>
204 + );
205 +}
added apps/web/src/components/globe/globe.tsx +199 −0
@@ -0,0 +1,199 @@
1 +'use client';
2 +/**
3 + * Globe orchestrator (client-only; loaded through `LazyGlobe`). Owns filters, selection, focus and the overlay UI;
4 + * the WebGL scene lives in `globe-scene.tsx`. Two variants: `hero` (compact, homepage) and `full` (/explore).
5 + */
6 +import { Info, SlidersHorizontal } from 'lucide-react';
7 +import { Component, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
8 +import { cn } from '@/lib/cn';
9 +import { fmtInt } from '@/lib/format';
10 +import { activeFilterCount, computeVisibility, DEFAULT_FILTERS, toggleIn, type GlobeFilters } from './filters';
11 +import { FocusSearch, type FocusTarget } from './focus-search';
12 +import { hasWebGL } from './geo';
13 +import { GlobeSkeleton, GlobeUnavailable } from './globe-fallback';
14 +import { GlobeFilterChips } from './globe-filters';
15 +import { GlobeLegend } from './globe-legend';
16 +import { GlobeScene } from './globe-scene';
17 +import { SatPanel } from './sat-panel';
18 +import { lerpFactor, type PickResult } from './satellite-points';
19 +import { usePositions } from './use-positions';
20 +
21 +export interface GlobeProps {
22 + variant?: 'hero' | 'full';
23 + className?: string;
24 + /** NORAD to focus once positions are loaded (e.g. `/explore?focus=25544`). */
25 + initialFocus?: number | null;
26 +}
27 +
28 +class SceneBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, { failed: boolean }> {
29 + state = { failed: false };
30 + static getDerivedStateFromError() {
31 + return { failed: true };
32 + }
33 + render() {
34 + return this.state.failed ? this.props.fallback : this.props.children;
35 + }
36 +}
37 +
38 +const ALT_NOTE = 'Altitude scale is compressed for legibility: r = 1 + 0.06 + ln(1 + alt/400 km) × 0.12. Low orbits are exaggerated, MEO/GEO compressed; the GEO ring still reads as a ring.';
39 +
40 +function utcClock(ms: number): string {
41 + const d = new Date(ms);
42 + return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')} UTC`;
43 +}
44 +
45 +export default function Globe({ variant = 'full', className, initialFocus = null }: GlobeProps) {
46 + const [webgl] = useState(() => hasWebGL());
47 + const { data, error, loading } = usePositions(webgl);
48 + const [filters, setFilters] = useState<GlobeFilters>(DEFAULT_FILTERS);
49 + const [selected, setSelected] = useState<number | null>(null); // NORAD
50 + const [focus, setFocus] = useState<FocusTarget | null>(null);
51 + const [focusRequest, setFocusRequest] = useState(0);
52 + const [focusMissing, setFocusMissing] = useState<string | null>(null);
53 + const [filtersOpen, setFiltersOpen] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024);
54 + const initialDone = useRef(false);
55 +
56 + const { flags, visible, flagsVersion } = useMemo(() => {
57 + if (!data) return { flags: new Float32Array(0), visible: 0, flagsVersion: 0 };
58 + const f = new Float32Array(data.n);
59 + const v = computeVisibility(data, filters, f);
60 + return { flags: f, visible: v, flagsVersion: Date.now() };
61 + }, [data, filters]);
62 +
63 + const highlightIndex = useMemo(() => {
64 + if (!data || selected === null) return null;
65 + const i = data.norad.indexOf(selected);
66 + return i >= 0 ? i : null;
67 + }, [data, selected]);
68 +
69 + const isSmall = () => typeof window !== 'undefined' && window.innerWidth < 768;
70 +
71 + // On phones the selection sheet and the filters sheet share the bottom of the screen: keep only one open.
72 + const select = useCallback((norad: number | null) => {
73 + setSelected(norad);
74 + if (norad !== null && isSmall()) setFiltersOpen(false);
75 + }, []);
76 + const toggleFilters = () => {
77 + if (!filtersOpen && isSmall()) setSelected(null);
78 + setFiltersOpen(!filtersOpen);
79 + };
80 +
81 + const onPick = useCallback(
82 + (r: PickResult | null) => {
83 + if (r) {
84 + select(r.norad);
85 + setFocusMissing(null);
86 + } else select(null);
87 + },
88 + [select],
89 + );
90 +
91 + const focusOn = useCallback(
92 + (t: FocusTarget) => {
93 + setFocus(t);
94 + select(t.norad);
95 + if (data && data.norad.indexOf(t.norad) < 0) {
96 + setFocusMissing(`${t.name} is not in the live snapshot (no current element set${data.capped ? ' or hidden by the device cap' : ''}).`);
97 + } else {
98 + setFocusMissing(null);
99 + setFocusRequest((n) => n + 1);
100 + }
101 + },
102 + [data, select],
103 + );
104 +
105 + useEffect(() => {
106 + if (!data || initialDone.current || initialFocus === null) return;
107 + initialDone.current = true;
108 + focusOn({ norad: initialFocus, name: `NORAD ${initialFocus}`, slug: String(initialFocus) });
109 + }, [data, initialFocus, focusOn]);
110 +
111 + const clearFocus = () => {
112 + setFocus(null);
113 + setSelected(null);
114 + setFocusMissing(null);
115 + };
116 +
117 + const panelSnapshot = useMemo(() => {
118 + if (!data || highlightIndex === null) return { altitudeKm: null, velocityKmS: null, orbitClass: null, active: null };
119 + const f = Math.min(1, lerpFactor(data, Date.now()));
120 + const alt = (data.alt0[highlightIndex] ?? 0) + ((data.alt1[highlightIndex] ?? 0) - (data.alt0[highlightIndex] ?? 0)) * f;
121 + return { altitudeKm: alt, velocityKmS: data.vel[highlightIndex] ?? null, orbitClass: data.legend.cls[data.cls[highlightIndex] ?? 0] ?? null, active: data.active[highlightIndex] === 1 };
122 + }, [data, highlightIndex]);
123 +
124 + if (!webgl) return <GlobeUnavailable className={className} />;
125 +
126 + const nFilters = activeFilterCount(filters);
127 + const isHero = variant === 'hero';
128 +
129 + return (
130 + <div className={cn('relative h-full w-full overflow-hidden', className)}>
131 + <div className="absolute inset-0">
132 + <SceneBoundary fallback={<GlobeUnavailable reason="The 3D renderer failed to start on this device." />}>
133 + <GlobeScene data={data} flags={flags} flagsVersion={flagsVersion} highlight={highlightIndex} focusRequest={focusRequest} focusNorad={isHero ? null : focus?.norad ?? selected} onPick={onPick} variant={variant} />
134 + </SceneBoundary>
135 + </div>
136 + {loading && !data && <GlobeSkeleton className="pointer-events-none absolute inset-0 opacity-70" label="Loading live positions…" />}
137 +
138 + {/* ---- overlays -------------------------------------------------------------------------------------- */}
139 + {!isHero && (
140 + <div className="pointer-events-none absolute inset-x-0 top-0 p-3 md:p-4">
141 + <div className="pointer-events-auto flex flex-wrap items-start gap-2">
142 + <FocusSearch onPick={focusOn} current={focus} onClear={clearFocus} className="w-full md:w-[380px]" />
143 + <div className="flex flex-wrap items-center gap-2">
144 + <button type="button" onClick={toggleFilters} aria-expanded={filtersOpen} aria-controls="globe-filters" className={cn('inline-flex h-11 items-center gap-2 rounded-md border px-3 text-sm backdrop-blur-md', filtersOpen ? 'border-rule-strong bg-plane-3 text-ink' : 'border-rule bg-plane/85 text-ink-2 hover:text-ink')}>
145 + <SlidersHorizontal className="size-4" aria-hidden /> Filters {nFilters > 0 && <span className="mono rounded bg-accent px-1.5 text-2xs text-accent-ink">{nFilters}</span>}
146 + </button>
147 + <div className="mono inline-flex h-11 items-center rounded-md border border-rule bg-plane/85 text-2xs backdrop-blur-md" role="group" aria-label="Time">
148 + <button type="button" disabled className="h-full px-2.5 text-ink-3 disabled:cursor-not-allowed" title="Time travel is coming soon" aria-label="Minus one hour (soon)">−1h</button>
149 + <span className="inline-flex h-full items-center gap-1.5 border-x border-rule bg-plane-3 px-3 text-ink"><span className="dot pulse text-active" aria-hidden />Now</span>
150 + <button type="button" disabled className="h-full px-2.5 text-ink-3 disabled:cursor-not-allowed" title="Time travel is coming soon" aria-label="Plus one hour (soon)">+1h</button>
151 + <span className="pr-2.5 text-ink-3">soon</span>
152 + </div>
153 + </div>
154 + </div>
155 + {focusMissing && <p className="pointer-events-auto mt-2 inline-block rounded-md border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-warn">{focusMissing}</p>}
156 + </div>
157 + )}
158 +
159 + {!isHero && filtersOpen && (
160 + <div id="globe-filters" className="panel absolute inset-x-3 bottom-[58px] z-10 max-h-[55%] overflow-y-auto p-4 md:inset-x-auto md:bottom-auto md:left-4 md:top-[124px] md:w-[360px] md:max-h-[calc(100%-190px)]">
161 + <div className="mb-2 flex items-center justify-between">
162 + <p className="text-sm font-medium text-ink">Filters</p>
163 + <button type="button" onClick={() => setFiltersOpen(false)} className="min-h-[36px] px-2 text-xs text-ink-3 hover:text-ink">Close</button>
164 + </div>
165 + <GlobeFilterChips data={data} filters={filters} onChange={setFilters} />
166 + </div>
167 + )}
168 +
169 + {/* status bar (both variants) */}
170 + <div className={cn('pointer-events-none absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-between gap-x-3 gap-y-1.5', isHero ? 'bg-gradient-to-t from-space via-space/70 to-transparent px-3 pb-3 pt-10' : 'border-t border-rule bg-space/60 px-3 py-2 backdrop-blur-md md:px-4')}>
171 + <GlobeLegend data={data} filters={filters} onToggle={(c) => setFilters((f) => ({ ...f, cls: toggleIn(f.cls, c) }))} className="pointer-events-auto" dense={isHero} />
172 + <div className="mono pointer-events-auto min-w-0 text-2xs leading-4 text-ink-3">
173 + {data ? (
174 + <>
175 + <span className="text-ink-2">{fmtInt(data.total)} objects</span>
176 + {data.capped && <span> · {fmtInt(data.n)} rendered on this device</span>}
177 + {nFilters > 0 && <span> · {fmtInt(visible)} shown</span>}
178 + <span> · positions as of {utcClock(data.t0)}</span>
179 + {!isHero && <span> · SGP4 from public element sets · refresh 30 s</span>}
180 + <span className="inline-flex items-center align-middle" title={ALT_NOTE}>
181 + <Info className="ml-1 size-3.5" aria-hidden />
182 + <span className="sr-only">{ALT_NOTE}</span>
183 + </span>
184 + </>
185 + ) : error ? (
186 + <span className="text-warn">{error}</span>
187 + ) : (
188 + <span>Loading positions…</span>
189 + )}
190 + {!isHero && <p className="mt-0.5 max-w-[60ch] text-ink-3/80">Indicative positions from public element sets; not for operational or safety-critical use.</p>}
191 + </div>
192 + </div>
193 +
194 + {selected !== null && (
195 + <SatPanel norad={selected} snapshot={panelSnapshot} onClose={() => setSelected(null)} className={cn('absolute inset-x-0 bottom-0 z-20 rounded-b-none md:inset-x-auto md:bottom-auto md:right-3 md:top-3 md:w-[340px] md:rounded-xl', isHero ? 'md:top-3' : 'md:top-[76px]')} />
196 + )}
197 + </div>
198 + );
199 +}
added apps/web/src/components/globe/lazy-globe.tsx +11 −0
@@ -0,0 +1,11 @@
1 +'use client';
2 +/** Client island: loads the Three.js globe only in the browser, showing the skeleton shell immediately. */
3 +import dynamic from 'next/dynamic';
4 +import { GlobeSkeleton } from './globe-fallback';
5 +import type { GlobeProps } from './globe';
6 +
7 +const Globe = dynamic(() => import('./globe'), { ssr: false, loading: () => <GlobeSkeleton /> });
8 +
9 +export function LazyGlobe(props: GlobeProps) {
10 + return <Globe {...props} />;
11 +}
added apps/web/src/components/globe/sat-panel.tsx +135 −0
@@ -0,0 +1,135 @@
1 +'use client';
2 +/**
3 + * Selected-satellite panel: bottom sheet on mobile, right column on desktop. Metadata from `/satellites/{norad}`,
4 + * live altitude/velocity polled from `/satellites/{norad}/live` every 5 s while open.
5 + */
6 +import { ArrowUpRight, X } from 'lucide-react';
7 +import Link from 'next/link';
8 +import { useEffect, useState } from 'react';
9 +import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';
10 +import { clientApi } from '@/lib/client-api';
11 +import { cn } from '@/lib/cn';
12 +import { fmt2, fmtAgo, fmtKm, fmtMinutes } from '@/lib/format';
13 +import { MISSION_LABELS, routes } from '@/lib/site';
14 +import type { LivePosition, SatelliteRow } from '@/lib/types';
15 +
16 +const LIVE_MS = 5_000;
17 +
18 +export interface PanelSnapshot {
19 + altitudeKm: number | null;
20 + velocityKmS: number | null;
21 + orbitClass: string | null;
22 + active: boolean | null;
23 +}
24 +
25 +function Row({ label, children }: { label: string; children: React.ReactNode }) {
26 + return (
27 + <div className="flex items-baseline justify-between gap-3 border-b border-rule py-1.5 text-sm last:border-0">
28 + <span className="text-ink-3">{label}</span>
29 + <span className="tnum min-w-0 truncate text-right text-ink">{children}</span>
30 + </div>
31 + );
32 +}
33 +
34 +export function SatPanel({ norad, snapshot, onClose, className }: { norad: number; snapshot: PanelSnapshot; onClose: () => void; className?: string }) {
35 + const [sat, setSat] = useState<SatelliteRow | null>(null);
36 + const [live, setLive] = useState<LivePosition | null>(null);
37 + const [error, setError] = useState<string | null>(null);
38 +
39 + useEffect(() => {
40 + setSat(null);
41 + setLive(null);
42 + setError(null);
43 + const ctrl = new AbortController();
44 + clientApi
45 + .satellite(String(norad), ctrl.signal)
46 + .then((r) => setSat(r.data))
47 + .catch((e: Error) => {
48 + if (e.name !== 'AbortError') setError('Metadata unavailable');
49 + });
50 + let timer: ReturnType<typeof setTimeout> | null = null;
51 + const poll = async () => {
52 + try {
53 + const r = await clientApi.live(String(norad), ctrl.signal);
54 + setLive(r.data);
55 + } catch {
56 + /* keep last */
57 + } finally {
58 + if (!ctrl.signal.aborted) timer = setTimeout(poll, LIVE_MS);
59 + }
60 + };
61 + void poll();
62 + return () => {
63 + ctrl.abort();
64 + if (timer) clearTimeout(timer);
65 + };
66 + }, [norad]);
67 +
68 + const alt = live?.altitude_km ?? snapshot.altitudeKm;
69 + const vel = live?.velocity_km_s ?? snapshot.velocityKmS;
70 + const name = sat?.name ?? (error ? `NORAD ${norad}` : null);
71 +
72 + return (
73 + <aside className={cn('panel flex max-h-[60%] flex-col overflow-hidden md:max-h-none', className)} aria-label="Selected satellite" aria-live="polite">
74 + <div className="flex items-start gap-3 border-b border-rule px-4 py-3">
75 + <div className="min-w-0 flex-1">
76 + <p className="eyebrow">Selected object</p>
77 + {name ? (
78 + <h3 className="mt-0.5 truncate text-base font-semibold leading-tight text-ink">{name}</h3>
79 + ) : (
80 + <div className="mt-1 h-5 w-40 animate-pulse rounded bg-plane-2" aria-hidden />
81 + )}
82 + <p className="mono mt-1 flex flex-wrap items-center gap-x-2 text-2xs text-ink-3">
83 + <span>NORAD {norad}</span>
84 + {sat?.cospar_id && <span>{sat.cospar_id}</span>}
85 + </p>
86 + </div>
87 + <button type="button" onClick={onClose} className="-mr-1 -mt-1 flex size-11 shrink-0 items-center justify-center rounded-md text-ink-3 hover:bg-plane-2 hover:text-ink" aria-label="Close panel">
88 + <X className="size-5" aria-hidden />
89 + </button>
90 + </div>
91 + <div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-4 py-3">
92 + <div className="flex flex-wrap items-center gap-1.5">
93 + <StatusBadge status={sat?.status ?? (snapshot.active === null ? undefined : snapshot.active ? 'ACTIVE' : 'INACTIVE')} />
94 + <OrbitBadge orbitClass={sat?.orbit_class ?? snapshot.orbitClass} />
95 + {sat && <TypeBadge type={sat.object_type} />}
96 + </div>
97 + <div className="mt-3 grid grid-cols-2 gap-3">
98 + <div>
99 + <p className="eyebrow">Altitude</p>
100 + <p className="tnum mt-0.5 text-xl font-semibold text-ink">{alt === null ? '—' : fmtKm(alt)}</p>
101 + </div>
102 + <div>
103 + <p className="eyebrow">Velocity</p>
104 + <p className="tnum mt-0.5 text-xl font-semibold text-ink">{vel === null ? '—' : `${fmt2(vel)} km/s`}</p>
105 + </div>
106 + </div>
107 + <p className="mono mt-1 text-2xs text-ink-3">{live ? `live · lat ${fmt2(live.lat)}° lon ${fmt2(live.lon)}° · epoch ${fmtAgo(live.source_epoch)}` : 'from snapshot · live position loading…'}</p>
108 + <div className="mt-3">
109 + {sat ? (
110 + <>
111 + <Row label="Operator">{sat.operator_slug ? <Link href={routes.operator(sat.operator_slug)} className="link">{sat.operator_name}</Link> : sat.operator_name ?? sat.owner_name ?? '—'}</Row>
112 + <Row label="Constellation">{sat.constellation_slug ? <Link href={routes.constellation(sat.constellation_slug)} className="link">{sat.constellation_name}</Link> : '—'}</Row>
113 + <Row label="Country">{sat.country_slug ? <Link href={routes.country(sat.country_slug)} className="link">{sat.country_name}</Link> : sat.country_name ?? '—'}</Row>
114 + <Row label="Mission (derived)">{MISSION_LABELS[sat.mission_type ?? 'unknown'] ?? sat.mission_type ?? '—'}</Row>
115 + <Row label="Perigee / apogee">{fmtKm(sat.perigee_km)} / {fmtKm(sat.apogee_km)}</Row>
116 + <Row label="Period">{fmtMinutes(sat.period_minutes)}</Row>
117 + <Row label="Launched">{sat.launch_date ?? '—'}</Row>
118 + </>
119 + ) : error ? (
120 + <p className="text-sm text-ink-3">{error}</p>
121 + ) : (
122 + <div className="space-y-2" aria-hidden>
123 + {[0, 1, 2, 3].map((i) => <div key={i} className="h-4 animate-pulse rounded bg-plane-2" />)}
124 + </div>
125 + )}
126 + </div>
127 + </div>
128 + <div className="border-t border-rule px-4 py-3">
129 + <Link href={sat ? routes.satellite(sat.slug) : routes.satellite(String(norad))} className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-md bg-accent text-sm font-medium text-accent-ink hover:brightness-110">
130 + Open satellite page <ArrowUpRight className="size-4" aria-hidden />
131 + </Link>
132 + </div>
133 + </aside>
134 + );
135 +}
modified apps/web/src/components/globe/satellite-points.tsx +15 −11
@@ -146,9 +146,11 @@ export function SatellitePoints({ data, flags, flagsVersion, highlight, highligh
146 146 } else if (ringRef.current) ringRef.current.visible = false;
147 147 });
148 148
149 − // CPU picking in screen space (click/tap without drag), occluded by the globe.
149 + // CPU picking in screen space (click/tap without drag), occluded by the globe. Listeners attach once per canvas;
150 + // everything else is read through a ref so re-renders (e.g. OrbitControls onStart) never drop a pointerdown.
151 + const latest = useRef({ data, flags, camera, size, onPick });
152 + latest.current = { data, flags, camera, size, onPick };
150 153 useEffect(() => {
151 − if (!onPick) return;
152 154 const el = gl.domElement;
153 155 let sx = 0;
154 156 let sy = 0;
@@ -160,19 +162,21 @@ export function SatellitePoints({ data, flags, flagsVersion, highlight, highligh
160 162 st = performance.now();
161 163 };
162 164 const up = (e: PointerEvent) => {
165 + const { data: d, flags: fl, camera: cam3, size: sz, onPick: pick } = latest.current;
166 + if (!pick) return;
163 167 if (Math.hypot(e.clientX - sx, e.clientY - sy) > 6 || performance.now() - st > 600) return;
164 168 const rect = el.getBoundingClientRect();
165 169 const px = e.clientX - rect.left;
166 170 const py = e.clientY - rect.top;
167 171 const tol = (e.pointerType === 'touch' ? 22 : 12) ** 2;
168 172 const p = cur.current;
169 − const cam = camera.position;
173 + const cam = cam3.position;
170 174 const camLen2 = cam.lengthSq();
171 175 let best = -1;
172 176 let bestD = Infinity;
173 177 let bestScore = Infinity;
174 − for (let i = 0; i < data.n; i++) {
175 − if ((flags[i] ?? 1) < 0.5) continue;
178 + for (let i = 0; i < d.n; i++) {
179 + if ((fl[i] ?? 1) < 0.5) continue;
176 180 v.set(p[i * 3]!, p[i * 3 + 1]!, p[i * 3 + 2]!);
177 181 // Occlusion: closest approach of the camera→point line to the origin, restricted to the segment.
178 182 const dx = v.x - cam.x;
@@ -186,10 +190,10 @@ export function SatellitePoints({ data, flags, flagsVersion, highlight, highligh
186 190 const cz = cam.z + dz * t;
187 191 if (cx * cx + cy * cy + cz * cz < 1) continue;
188 192 }
189 − v.project(camera);
193 + v.project(cam3);
190 194 if (v.z > 1) continue;
191 − const x = ((v.x + 1) / 2) * size.width;
192 − const y = ((1 - v.y) / 2) * size.height;
195 + const x = ((v.x + 1) / 2) * sz.width;
196 + const y = ((1 - v.y) / 2) * sz.height;
193 197 const d2 = (x - px) ** 2 + (y - py) ** 2;
194 198 if (d2 > tol) continue;
195 199 // Prefer close-to-cursor, then nearer to camera.
@@ -200,8 +204,8 @@ export function SatellitePoints({ data, flags, flagsVersion, highlight, highligh
200 204 best = i;
201 205 }
202 206 }
203 − if (best >= 0 && bestD <= tol) onPick({ index: best, norad: data.norad[best] ?? 0 });
204 − else onPick(null);
207 + if (best >= 0 && bestD <= tol) pick({ index: best, norad: d.norad[best] ?? 0 });
208 + else pick(null);
205 209 };
206 210 el.addEventListener('pointerdown', down);
207 211 el.addEventListener('pointerup', up);
@@ -209,7 +213,7 @@ export function SatellitePoints({ data, flags, flagsVersion, highlight, highligh
209 213 el.removeEventListener('pointerdown', down);
210 214 el.removeEventListener('pointerup', up);
211 215 };
212 − }, [gl, camera, size, data, flags, onPick]);
216 + }, [gl]);
213 217
214 218 const ringColor = useMemo(() => new THREE.Color(token('--accent')), []);
215 219
added apps/web/src/components/home/activity.tsx +105 −0
@@ -0,0 +1,105 @@
1 +import Link from 'next/link';
2 +import { TypeBadge } from '@/components/ui/badges';
3 +import { Section } from '@/components/ui/section';
4 +import { Unavailable } from '@/components/ui/unavailable';
5 +import { fmtAgo, fmtDate, fmtInt } from '@/lib/format';
6 +import { EVENT_TYPE_LABELS, routes } from '@/lib/site';
7 +import type { HomePayload } from '@/lib/types';
8 +
9 +export function LatestLaunches({ items }: { items: HomePayload['latest_launches'] }) {
10 + return (
11 + <Section eyebrow="Launches" title="Latest launches" action={{ href: routes.launches(), label: 'Launch log' }} className="py-0">
12 + {items.length === 0 ? (
13 + <Unavailable what="Latest launches" />
14 + ) : (
15 + <ul className="divide-y divide-rule border-t border-rule">
16 + {items.map((l) => (
17 + <li key={l.id}>
18 + <Link href={routes.launch(l.cospar_launch_id)} className="group grid min-h-[56px] grid-cols-[5.5rem_minmax(0,1fr)_auto] items-center gap-x-3 py-2.5">
19 + <span className="mono text-xs text-ink-3">{fmtDate(l.launch_date)}</span>
20 + <span className="min-w-0">
21 + <span className="block truncate text-[15px] text-ink group-hover:text-accent">{l.primary_name ?? l.cospar_launch_id}</span>
22 + <span className="block truncate text-2xs text-ink-3">
23 + <span className="mono">{l.cospar_launch_id}</span>
24 + {l.site_name && <> · {l.site_name}</>}
25 + {l.site_country && <> · {l.site_country}</>}
26 + </span>
27 + </span>
28 + <span className="tnum text-right text-sm text-ink">
29 + {fmtInt(l.payload_count)} <span className="text-2xs text-ink-3">payload{(Number(l.payload_count) || 0) === 1 ? '' : 's'}</span>
30 + </span>
31 + </Link>
32 + </li>
33 + ))}
34 + </ul>
35 + )}
36 + </Section>
37 + );
38 +}
39 +
40 +export function RecentEvents({ items }: { items: HomePayload['events'] }) {
41 + return (
42 + <Section eyebrow="Events" title="Recent orbital events" action={{ href: routes.events(), label: 'Event feed' }} className="py-0">
43 + {items.length === 0 ? (
44 + <Unavailable what="Events" />
45 + ) : (
46 + <ul className="divide-y divide-rule border-t border-rule">
47 + {items.slice(0, 8).map((e) => {
48 + const primary = e.entities?.[0];
49 + const href = primary?.type === 'launch' && primary.slug ? routes.launch(primary.slug) : primary?.type === 'satellite' && primary.slug ? routes.satellite(primary.slug) : routes.events(`type=${e.type}`);
50 + return (
51 + <li key={e.id}>
52 + <Link href={href} className="group flex min-h-[56px] items-start gap-3 py-2.5">
53 + <span className="mt-0.5 inline-flex shrink-0 rounded border border-rule px-1.5 py-0.5 text-2xs text-ink-2">{EVENT_TYPE_LABELS[e.type] ?? e.type}</span>
54 + <span className="min-w-0 flex-1">
55 + <span className="block truncate text-[15px] text-ink group-hover:text-accent">{e.title}</span>
56 + {e.summary && <span className="block truncate text-2xs text-ink-3">{e.summary}</span>}
57 + </span>
58 + <span className="mono shrink-0 pt-0.5 text-2xs text-ink-3">{fmtAgo(e.event_time)}</span>
59 + </Link>
60 + </li>
61 + );
62 + })}
63 + </ul>
64 + )}
65 + </Section>
66 + );
67 +}
68 +
69 +export function RecentReentries({ items }: { items: HomePayload['reentries'] }) {
70 + return (
71 + <Section eyebrow="Reentries" title="Recent reentries" action={{ href: routes.reentries(), label: 'All reentries' }} className="py-0">
72 + {items.length === 0 ? (
73 + <Unavailable what="Reentries" />
74 + ) : (
75 + <table className="data-table stack">
76 + <thead>
77 + <tr>
78 + <th>Object</th>
79 + <th>NORAD</th>
80 + <th>Type</th>
81 + <th>Decayed</th>
82 + <th>Country</th>
83 + </tr>
84 + </thead>
85 + <tbody>
86 + {items.map((r) => (
87 + <tr key={r.id}>
88 + <td className="primary">
89 + <Link href={routes.satellite(r.slug)} className="link">{r.name}</Link>
90 + </td>
91 + <td data-label="NORAD" className="mono text-xs text-ink-2">{r.norad_id ?? '—'}</td>
92 + <td data-label="Type">
93 + <TypeBadge type={r.object_type} />
94 + </td>
95 + <td data-label="Decayed" className="mono text-xs text-ink-2">{fmtDate(r.decay_date)}</td>
96 + <td data-label="Country" className="text-sm text-ink-2">{r.country_name ?? r.country_code ?? '—'}</td>
97 + </tr>
98 + ))}
99 + </tbody>
100 + </table>
101 + )}
102 + <p className="mt-3 text-2xs text-ink-3">Decay dates come from the public catalogue; SatelliteIndex does not predict reentry locations.</p>
103 + </Section>
104 + );
105 +}
added apps/web/src/components/home/density.tsx +75 −0
@@ -0,0 +1,75 @@
1 +import Link from 'next/link';
2 +import { Section } from '@/components/ui/section';
3 +import { Unavailable } from '@/components/ui/unavailable';
4 +import { fmtInt, num } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import type { BucketStat } from '@/lib/types';
7 +
8 +const SPECIAL_ORDER = ['MEO', 'GEO', 'HEO', 'OTHER', 'UNKNOWN'];
9 +
10 +/** 0-200 … 1000-2000 by lower bound, then MEO, GEO, HEO, other/unknown. */
11 +export function orderBuckets(buckets: BucketStat[]): BucketStat[] {
12 + const rank = (b: string) => {
13 + const m = /^(\d+)-/.exec(b);
14 + if (m) return Number(m[1]);
15 + const i = SPECIAL_ORDER.indexOf(b.toUpperCase());
16 + return 1_000_000 + (i < 0 ? SPECIAL_ORDER.length : i);
17 + };
18 + return [...buckets].sort((a, b) => rank(a.bucket) - rank(b.bucket));
19 +}
20 +
21 +function label(b: string): string {
22 + return /^\d+-\d+$/.test(b) ? `${b} km` : b === 'UNKNOWN' ? 'Unknown' : b === 'OTHER' ? 'Other' : b;
23 +}
24 +
25 +const SEGMENTS: { key: keyof BucketStat; label: string; color: string }[] = [
26 + { key: 'active_payloads', label: 'Active payloads', color: 'var(--series-1)' },
27 + { key: 'payloads', label: 'Inactive payloads', color: 'var(--series-7)' },
28 + { key: 'rocket_bodies', label: 'Rocket bodies', color: 'var(--series-4)' },
29 + { key: 'debris', label: 'Debris', color: 'var(--series-8)' },
30 +];
31 +
32 +/** Orbital density as a horizontal bar profile (one row per altitude shell), stacked by object category. */
33 +export function OrbitalDensity({ buckets }: { buckets: BucketStat[] }) {
34 + const rows = orderBuckets(buckets);
35 + const max = Math.max(1, ...rows.map((b) => num(b.objects) ?? 0));
36 + return (
37 + <Section eyebrow="Orbital density" title="Where the objects are" action={{ href: routes.stats(), label: 'Full statistics' }} className="py-0">
38 + <p className="mb-4 max-w-2xl text-xs text-ink-3">
39 + Objects on orbit by altitude shell (perigee) and orbit regime. Derived from the latest element sets — see the <Link href={routes.methodology()} className="link">methodology</Link>. Not a collision-risk metric.
40 + </p>
41 + {rows.length === 0 ? (
42 + <Unavailable what="Orbital density" />
43 + ) : (
44 + <>
45 + <ol className="space-y-1.5">
46 + {rows.map((b) => {
47 + const total = num(b.objects) ?? 0;
48 + const active = num(b.active_payloads) ?? 0;
49 + const payloads = Math.max(0, (num(b.payloads) ?? 0) - active);
50 + const parts = [active, payloads, num(b.rocket_bodies) ?? 0, num(b.debris) ?? 0];
51 + return (
52 + <li key={b.bucket} className="grid grid-cols-[5.5rem_minmax(0,1fr)_4rem] items-center gap-3 sm:grid-cols-[7rem_minmax(0,1fr)_5rem]">
53 + <span className="mono truncate text-xs text-ink-2">{label(b.bucket)}</span>
54 + <span className="flex h-[14px] w-full overflow-hidden rounded-sm bg-plane-2" role="img" aria-label={`${label(b.bucket)}: ${fmtInt(total)} objects`}>
55 + {parts.map((v, i) => (
56 + <span key={SEGMENTS[i]!.key} className="h-full" style={{ width: `${(v / max) * 100}%`, background: SEGMENTS[i]!.color, opacity: i === 1 ? 0.5 : 0.9 }} />
57 + ))}
58 + </span>
59 + <span className="tnum text-right text-xs text-ink">{fmtInt(total)}</span>
60 + </li>
61 + );
62 + })}
63 + </ol>
64 + <ul className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-ink-2">
65 + {SEGMENTS.map((s, i) => (
66 + <li key={s.key} className="inline-flex items-center gap-1.5">
67 + <span className="inline-block size-2.5 rounded-sm" style={{ background: s.color, opacity: i === 1 ? 0.5 : 0.9 }} /> {s.label}
68 + </li>
69 + ))}
70 + </ul>
71 + </>
72 + )}
73 + </Section>
74 + );
75 +}
added apps/web/src/components/home/featured.tsx +41 −0
@@ -0,0 +1,41 @@
1 +import Link from 'next/link';
2 +import { OrbitBadge, StatusBadge } from '@/components/ui/badges';
3 +import { Section } from '@/components/ui/section';
4 +import { Unavailable } from '@/components/ui/unavailable';
5 +import { fmtInt, fmtKm, num } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import type { HomePayload } from '@/lib/types';
8 +
9 +/** Trending (by views) — or "Featured" when the view counters are still at zero. Honest label, real rows. */
10 +export function FeaturedObjects({ items }: { items: HomePayload['trending'] }) {
11 + const hasViews = items.some((t) => (num(t.views) ?? 0) > 0);
12 + return (
13 + <Section eyebrow={hasViews ? 'Trending' : 'Featured'} title={hasViews ? 'Most viewed objects' : 'Notable objects'} action={{ href: routes.explore(), label: 'Find them on the globe' }} className="py-0">
14 + {items.length === 0 ? (
15 + <Unavailable what="Featured objects" />
16 + ) : (
17 + <ul className="divide-y divide-rule border-t border-rule">
18 + {items.map((t) => (
19 + <li key={t.id}>
20 + <Link href={routes.satellite(t.slug)} className="group grid min-h-[56px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 py-2.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto_auto]">
21 + <span className="min-w-0">
22 + <span className="block truncate text-[15px] font-medium text-ink group-hover:text-accent">{t.name}</span>
23 + <span className="mono block text-2xs text-ink-3">NORAD {t.norad_id ?? '—'}</span>
24 + </span>
25 + <span className="col-span-2 min-w-0 truncate text-xs text-ink-2 md:col-span-1">{[t.operator_name, t.constellation_name].filter(Boolean).join(' · ') || '—'}</span>
26 + <span className="flex items-center gap-1.5 md:justify-end">
27 + <OrbitBadge orbitClass={t.orbit_class} />
28 + <StatusBadge status={t.status} />
29 + </span>
30 + <span className="tnum hidden text-right text-xs text-ink-3 md:block" title="Perigee">
31 + {fmtKm(t.perigee_km)}
32 + {hasViews && <span className="ml-2">{fmtInt(t.views)} views</span>}
33 + </span>
34 + </Link>
35 + </li>
36 + ))}
37 + </ul>
38 + )}
39 + </Section>
40 + );
41 +}
added apps/web/src/components/home/hero.tsx +67 −0
@@ -0,0 +1,67 @@
1 +import { ArrowRight } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { LazyGlobe } from '@/components/globe/lazy-globe';
4 +import { Container } from '@/components/ui/section';
5 +import { fmtInt } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import type { HomePayload } from '@/lib/types';
8 +import { SearchButton } from './search-button';
9 +
10 +function Count({ label, value, href }: { label: string; value: string; href: string }) {
11 + return (
12 + <Link href={href} className="group min-w-0 py-3 pr-4 md:py-0">
13 + <p className="tnum display text-2xl text-ink md:text-[2rem]">{value}</p>
14 + <p className="mt-1 text-2xs uppercase tracking-[0.12em] text-ink-3 group-hover:text-ink-2">{label}</p>
15 + </Link>
16 + );
17 +}
18 +
19 +/** Hero: statement + live counts (from /stats/home, never hardcoded) + CTAs, with the compact globe island beside/below. */
20 +export function HomeHero({ home }: { home: HomePayload | null }) {
21 + const s = home?.stats;
22 + const counts: [string, string, string][] = [
23 + ['Active satellites', fmtInt(s?.active_satellites), routes.satellites('status=ACTIVE')],
24 + ['Objects on orbit', fmtInt(s?.objects_on_orbit), routes.satellites('on_orbit=true')],
25 + ['Launches catalogued', fmtInt(home?.launches.total), routes.launches()],
26 + ['Active operators', fmtInt(s?.active_operators), routes.operators()],
27 + ['Countries', fmtInt(s?.active_countries), routes.countries()],
28 + ];
29 + return (
30 + <section className="relative overflow-hidden border-b border-rule">
31 + <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden />
32 + <Container wide className="relative">
33 + <div className="grid items-center gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)] lg:gap-8">
34 + <div className="pb-4 pt-10 md:pt-14 lg:py-20">
35 + <p className="eyebrow flex items-center gap-2">
36 + <span className="dot pulse text-active" aria-hidden /> Satellite Index · live catalogue
37 + </p>
38 + <h1 className="display mt-4 max-w-[14ch] text-[2.5rem] text-ink sm:text-5xl md:text-6xl lg:text-[4.25rem]">
39 + The world&rsquo;s orbital infrastructure, mapped and indexed.
40 + </h1>
41 + <p className="mt-5 max-w-xl text-[15px] leading-relaxed text-ink-2 md:text-lg">
42 + Every satellite, constellation, operator, launch, debris object and reentry in Earth orbit — one canonical, source-attributed
43 + database with live SGP4 positions, orbital history and a transparent methodology.
44 + </p>
45 + <div className="mt-7 flex flex-wrap items-center gap-3">
46 + <Link href={routes.explore()} className="inline-flex h-12 items-center justify-center gap-2 rounded-md bg-accent px-5 text-sm font-semibold text-accent-ink hover:brightness-110">
47 + Explore orbit <ArrowRight className="size-4" aria-hidden />
48 + </Link>
49 + <SearchButton />
50 + </div>
51 + <dl className="mt-9 grid grid-cols-2 gap-x-4 gap-y-2 border-y border-rule py-2 sm:grid-cols-3 md:mt-12 md:grid-cols-5 md:gap-6 md:border-0 md:py-0">
52 + {counts.map(([label, value, href]) => (
53 + <div key={label} className="[&:nth-child(5)]:col-span-2 sm:[&:nth-child(5)]:col-span-1 md:border-l md:border-rule md:pl-4 md:first:border-0 md:first:pl-0">
54 + <Count label={label} value={value} href={href} />
55 + </div>
56 + ))}
57 + </dl>
58 + {!home && <p className="mt-2 text-xs text-warn">Live statistics unavailable — the catalogue API did not respond.</p>}
59 + </div>
60 + <div className="relative -mx-4 h-[46vh] min-h-[300px] md:mx-0 md:h-[520px] lg:h-[680px]">
61 + <LazyGlobe variant="hero" />
62 + </div>
63 + </div>
64 + </Container>
65 + </section>
66 + );
67 +}
added apps/web/src/components/home/metrics.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import Link from 'next/link';
2 +import { Stat } from '@/components/ui/section';
3 +import { fmtInt } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import type { HomePayload } from '@/lib/types';
6 +
7 +/** Headline metrics strip — one hairline row, big tabular numbers, no cards. */
8 +export function HeadlineMetrics({ home }: { home: HomePayload }) {
9 + const s = home.stats;
10 + const items: { label: string; value: string; hint: string; href: string }[] = [
11 + { label: 'Payloads · 30 d', value: fmtInt(s.payloads_launched_30d), hint: `${fmtInt(home.launches.last_30d)} launches`, href: routes.launches() },
12 + { label: 'Payloads · YTD', value: fmtInt(s.payloads_launched_ytd), hint: `${fmtInt(home.launches.ytd)} launches`, href: routes.launches() },
13 + { label: 'Payloads · 365 d', value: fmtInt(s.payloads_launched_365d), hint: `${fmtInt(home.launches.last_365d)} launches`, href: routes.launches() },
14 + { label: 'Decays · 30 d', value: fmtInt(s.decayed_last_30d), hint: `${fmtInt(s.decayed_last_365d)} in 365 d`, href: routes.reentries() },
15 + { label: 'Debris on orbit', value: fmtInt(s.debris_on_orbit), hint: 'catalogued fragments', href: routes.debris() },
16 + { label: 'Rocket bodies', value: fmtInt(s.rocket_bodies_on_orbit), hint: 'on orbit', href: routes.debris() },
17 + { label: 'With element sets', value: fmtInt(s.with_elements), hint: 'tracked live', href: routes.explore() },
18 + ];
19 + return (
20 + <div className="grid grid-cols-2 gap-x-4 gap-y-6 border-b border-rule py-8 sm:grid-cols-4 lg:grid-cols-7">
21 + {items.map((it) => (
22 + <Link key={it.label} href={it.href} className="group min-w-0 border-l border-rule pl-3 odd:border-0 odd:pl-0 sm:odd:border-l sm:odd:pl-3 sm:[&:nth-child(4n+1)]:border-0 sm:[&:nth-child(4n+1)]:pl-0 lg:[&:nth-child(4n+1)]:border-l lg:[&:nth-child(4n+1)]:pl-3 lg:first:border-0 lg:first:pl-0">
23 + <Stat label={it.label} value={it.value} hint={it.hint} />
24 + </Link>
25 + ))}
26 + </div>
27 + );
28 +}
added apps/web/src/components/home/rankings.tsx +93 −0
@@ -0,0 +1,93 @@
1 +import Link from 'next/link';
2 +import { Section } from '@/components/ui/section';
3 +import { Unavailable } from '@/components/ui/unavailable';
4 +import { fmtInt, num, titleCase } from '@/lib/format';
5 +import { ORBIT_CLASS_COLORS, routes } from '@/lib/site';
6 +import type { HomePayload } from '@/lib/types';
7 +
8 +interface RankRow {
9 + key: string;
10 + href: string;
11 + label: string;
12 + sub?: string | null;
13 + value: number;
14 + color?: string;
15 +}
16 +
17 +/** Ranked list with proportional bars where every label is a link (the shared HBars is not linkable). */
18 +export function RankedBars({ rows, valueLabel }: { rows: RankRow[]; valueLabel: string }) {
19 + if (!rows.length) return <Unavailable what="Ranking" />;
20 + const max = Math.max(1, ...rows.map((r) => r.value));
21 + return (
22 + <ol className="divide-y divide-rule border-t border-rule">
23 + {rows.map((r, i) => (
24 + <li key={r.key}>
25 + <Link href={r.href} className="group grid min-h-[48px] grid-cols-[1.6rem_minmax(0,1fr)_auto] items-center gap-x-3 py-2">
26 + <span className="mono text-2xs text-ink-3">{String(i + 1).padStart(2, '0')}</span>
27 + <span className="min-w-0">
28 + <span className="flex items-baseline gap-2">
29 + <span className="truncate text-sm text-ink group-hover:text-accent">{r.label}</span>
30 + {r.sub && <span className="hidden truncate text-2xs text-ink-3 sm:inline">{r.sub}</span>}
31 + </span>
32 + <span className="mt-1 block h-[4px] w-full overflow-hidden rounded-full bg-plane-2">
33 + <span className="block h-full rounded-full" style={{ width: `${Math.max(1.5, (r.value / max) * 100)}%`, background: r.color ?? 'var(--series-1)' }} />
34 + </span>
35 + </span>
36 + <span className="tnum text-right text-sm text-ink">
37 + {fmtInt(r.value)} <span className="sr-only">{valueLabel}</span>
38 + </span>
39 + </Link>
40 + </li>
41 + ))}
42 + </ol>
43 + );
44 +}
45 +
46 +export function TopConstellations({ items }: { items: HomePayload['top_constellations'] }) {
47 + const rows: RankRow[] = items.map((c) => ({
48 + key: c.id,
49 + href: routes.constellation(c.slug),
50 + label: c.name,
51 + sub: [c.service_type ? titleCase(c.service_type) : null, c.orbit_class].filter(Boolean).join(' · '),
52 + value: num(c.active) ?? 0,
53 + color: ORBIT_CLASS_COLORS[c.orbit_class ?? 'OTHER'] ?? 'var(--series-1)',
54 + }));
55 + return (
56 + <Section eyebrow="Constellations" title="Largest constellations" action={{ href: routes.constellations(), label: 'All constellations' }} className="py-0">
57 + <p className="mb-3 text-xs text-ink-3">Active satellites · membership is derived (see <Link href={routes.methodology()} className="link">methodology</Link>)</p>
58 + <RankedBars rows={rows} valueLabel="active satellites" />
59 + </Section>
60 + );
61 +}
62 +
63 +export function TopCountries({ items }: { items: HomePayload['top_countries'] }) {
64 + const rows: RankRow[] = items.slice(0, 10).map((c) => ({
65 + key: c.code,
66 + href: routes.country(c.slug),
67 + label: c.name,
68 + sub: `${fmtInt(c.objects_on_orbit)} objects on orbit`,
69 + value: num(c.active_payloads) ?? 0,
70 + color: 'var(--series-6)',
71 + }));
72 + return (
73 + <Section eyebrow="Countries" title="Active payloads by country" action={{ href: routes.countries(), label: 'All countries' }} className="py-0">
74 + <RankedBars rows={rows} valueLabel="active payloads" />
75 + </Section>
76 + );
77 +}
78 +
79 +export function TopOperators({ items }: { items: HomePayload['top_operators'] }) {
80 + const rows: RankRow[] = items.slice(0, 10).map((o) => ({
81 + key: o.id,
82 + href: routes.operator(o.slug),
83 + label: o.name,
84 + sub: [o.country_code, num(o.payloads_last_365d) ? `${fmtInt(o.payloads_last_365d)} launched in 365 d` : null].filter(Boolean).join(' · '),
85 + value: num(o.active_payloads) ?? 0,
86 + color: 'var(--series-2)',
87 + }));
88 + return (
89 + <Section eyebrow="Operators" title="Largest operators" action={{ href: routes.operators(), label: 'All operators' }} className="py-0">
90 + <RankedBars rows={rows} valueLabel="active payloads" />
91 + </Section>
92 + );
93 +}
added apps/web/src/components/home/search-button.tsx +16 −0
@@ -0,0 +1,16 @@
1 +'use client';
2 +import { Search } from 'lucide-react';
3 +import { useOpenSearch } from '@/components/layout/search-context';
4 +import { cn } from '@/lib/cn';
5 +
6 +/** Secondary hero CTA: opens the global ⌘K search dialog. */
7 +export function SearchButton({ className }: { className?: string }) {
8 + const open = useOpenSearch();
9 + return (
10 + <button type="button" onClick={open} className={cn('inline-flex h-12 items-center justify-center gap-2 rounded-md border border-rule-strong px-5 text-sm font-medium text-ink hover:bg-plane-2', className)}>
11 + <Search className="size-4" aria-hidden />
12 + Search satellites
13 + <kbd className="mono ml-1 hidden rounded border border-rule px-1.5 py-0.5 text-[10px] text-ink-3 md:inline">⌘K</kbd>
14 + </button>
15 + );
16 +}
added apps/web/src/components/home/sources.tsx +88 −0
@@ -0,0 +1,88 @@
1 +import { ArrowRight, Code2 } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { FreshnessBadge } from '@/components/ui/badges';
4 +import { Section } from '@/components/ui/section';
5 +import { Unavailable } from '@/components/ui/unavailable';
6 +import { fmtAgo } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { HomePayload } from '@/lib/types';
9 +
10 +type Source = HomePayload['sources'][number];
11 +
12 +/** Freshness from the connector's own cadence: ≤ 2 intervals fresh, ≤ 6 aging, else stale. */
13 +export function freshnessOf(s: Source, now = Date.now()): 'fresh' | 'aging' | 'stale' | 'unavailable' | 'not_enabled' {
14 + if (!s.enabled) return 'not_enabled';
15 + if (!s.last_success_at) return 'unavailable';
16 + const age = (now - new Date(s.last_success_at).getTime()) / 1000;
17 + const iv = s.interval_seconds ?? 86_400;
18 + if (age <= iv * 2) return 'fresh';
19 + if (age <= iv * 6) return 'aging';
20 + return 'stale';
21 +}
22 +
23 +export function DataSources({ sources }: { sources: HomePayload['sources'] }) {
24 + const grouped = new Map<string, Source[]>();
25 + for (const s of sources) grouped.set(s.id, [...(grouped.get(s.id) ?? []), s]);
26 + const attributions = [...new Set(sources.map((s) => s.attribution_text).filter((t): t is string => Boolean(t)))];
27 + return (
28 + <Section eyebrow="Sources" title="Where the data comes from" action={{ href: routes.sources(), label: 'Sources & status' }} className="py-0">
29 + {grouped.size === 0 ? (
30 + <Unavailable what="Source status" />
31 + ) : (
32 + <ul className="divide-y divide-rule border-t border-rule">
33 + {[...grouped.entries()].map(([id, conns]) => {
34 + const first = conns[0]!;
35 + return (
36 + <li key={id} className="grid gap-2 py-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] md:gap-6">
37 + <div className="min-w-0">
38 + <p className="flex items-center gap-2 text-[15px] text-ink">
39 + {first.name}
40 + <span className="rounded border border-rule px-1.5 py-0.5 text-2xs text-ink-3">{first.official ? 'Official' : 'Public'}</span>
41 + </p>
42 + {first.attribution_text && <p className="mt-0.5 text-2xs text-ink-3">{first.attribution_text}</p>}
43 + </div>
44 + <ul className="space-y-1">
45 + {conns.map((c) => (
46 + <li key={c.connector ?? c.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-0.5 text-xs">
47 + <span className="mono text-ink-2">{c.connector ?? '—'}</span>
48 + <span className="flex items-center gap-3">
49 + <span className="tnum text-ink-3">updated {fmtAgo(c.last_success_at)}</span>
50 + <FreshnessBadge status={freshnessOf(c)} />
51 + </span>
52 + </li>
53 + ))}
54 + </ul>
55 + </li>
56 + );
57 + })}
58 + </ul>
59 + )}
60 + {attributions.length > 0 && <p className="mt-4 text-2xs leading-relaxed text-ink-3">{attributions.join(' ')} Derived classifications follow the published <Link href={routes.methodology()} className="link">methodology</Link>.</p>}
61 + </Section>
62 + );
63 +}
64 +
65 +export function ApiTeaser() {
66 + return (
67 + <Section eyebrow="Developers" title="Everything here is an API" className="py-0">
68 + <div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
69 + <p className="max-w-xl text-sm leading-relaxed text-ink-2">
70 + Every number on this page is served by the public JSON API — catalogue, search, live SGP4 positions, constellations, operators,
71 + launches, debris, reentries, events and the statistics snapshot. Interactive OpenAPI docs at{' '}
72 + <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">/api/v1/docs</code>.
73 + </p>
74 + <div className="mono rounded-lg border border-rule bg-plane/60 p-4 text-xs leading-6 text-ink-2">
75 + <p className="text-ink-3"># live position of the ISS</p>
76 + <p>GET /api/v1/satellites/25544/live</p>
77 + <p className="mt-2 text-ink-3"># all tracked objects, two SGP4 epochs</p>
78 + <p>GET /api/v1/orbit/positions</p>
79 + <p className="mt-2 text-ink-3"># full-text search across entities</p>
80 + <p>GET /api/v1/search?q=starlink</p>
81 + </div>
82 + </div>
83 + <Link href={routes.developers()} className="mt-4 inline-flex h-11 items-center gap-2 rounded-md border border-rule-strong px-4 text-sm text-ink hover:bg-plane-2">
84 + <Code2 className="size-4" aria-hidden /> API documentation <ArrowRight className="size-4" aria-hidden />
85 + </Link>
86 + </Section>
87 + );
88 +}
added apps/web/src/components/launches/launch-filters.tsx +132 −0
@@ -0,0 +1,132 @@
1 +import Link from 'next/link';
2 +import { routes } from '@/lib/site';
3 +import type { LaunchSiteRow, LaunchTimeline } from '@/lib/types';
4 +
5 +export const LAUNCH_KEYS = ['year', 'site', 'country', 'owner', 'min_payloads', 'after', 'before', 'q', 'sort', 'page'] as const;
6 +export type LaunchKey = (typeof LAUNCH_KEYS)[number];
7 +export type LaunchQuery = Partial<Record<LaunchKey, string>>;
8 +
9 +export const LAUNCH_SORTS: [string, string][] = [
10 + ['date', 'Newest first'],
11 + ['-date', 'Oldest first'],
12 + ['payloads', 'Most payloads'],
13 + ['objects', 'Most catalogued objects'],
14 +];
15 +
16 +export function parseLaunchQuery(sp: Record<string, string | string[] | undefined>): LaunchQuery {
17 + const q: LaunchQuery = {};
18 + for (const k of LAUNCH_KEYS) {
19 + const v = sp[k];
20 + const s = Array.isArray(v) ? v[0] : v;
21 + if (s && s.trim()) q[k] = s.trim().slice(0, 80);
22 + }
23 + return q;
24 +}
25 +
26 +export function launchHref(q: LaunchQuery, patch: LaunchQuery = {}, dropPage = true): string {
27 + const merged: LaunchQuery = { ...q, ...patch };
28 + if (dropPage) delete merged.page;
29 + const p = new URLSearchParams();
30 + for (const k of LAUNCH_KEYS) if (merged[k]) p.set(k, merged[k]!);
31 + const s = p.toString();
32 + return routes.launches(s || undefined);
33 +}
34 +
35 +export function launchTitle(q: LaunchQuery, sites: LaunchSiteRow[] | null): string {
36 + const site = sites?.find((s) => s.slug === q.site || s.code === q.site);
37 + let t = q.year ? `Launches in ${q.year}` : 'Orbital launches';
38 + if (site) t += ` from ${site.name}`;
39 + else if (q.site) t += ` from ${q.site}`;
40 + if (q.country) t += ` · ${q.country.toUpperCase()}`;
41 + if (q.owner) t += ` · owner ${q.owner.toUpperCase()}`;
42 + if (q.min_payloads) t += ` · ≥ ${q.min_payloads} payloads`;
43 + if (q.q) t += ` matching “${q.q}”`;
44 + return t;
45 +}
46 +
47 +const field = 'h-10 min-w-0 rounded-md border border-rule bg-plane-2 px-2 text-sm text-ink placeholder:text-ink-3';
48 +
49 +export function LaunchFilterForm({ q, timeline }: { q: LaunchQuery; timeline: LaunchTimeline | null }) {
50 + const years = timeline ? [...timeline.years].map((y) => y.year).sort((a, b) => b - a) : [];
51 + const sites = timeline ? [...timeline.sites].sort((a, b) => a.name.localeCompare(b.name)) : [];
52 + const countries = Array.from(new Set(sites.map((s) => s.country_code).filter((c): c is string => !!c))).sort();
53 + return (
54 + <form method="get" action={routes.launches()} className="rounded-lg border border-rule bg-plane/60 p-3 md:p-4">
55 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
56 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
57 + Year
58 + {years.length ? (
59 + <select name="year" defaultValue={q.year ?? ''} className={field}>
60 + <option value="">Any year</option>
61 + {years.map((y) => (
62 + <option key={y} value={y}>{y}</option>
63 + ))}
64 + </select>
65 + ) : (
66 + <input type="number" name="year" defaultValue={q.year ?? ''} placeholder="e.g. 2026" className={field} />
67 + )}
68 + </label>
69 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3 lg:col-span-2">
70 + Launch site
71 + {sites.length ? (
72 + <select name="site" defaultValue={q.site ?? ''} className={field}>
73 + <option value="">Any site</option>
74 + {sites.map((s) => (
75 + <option key={s.slug} value={s.slug}>{s.name}</option>
76 + ))}
77 + </select>
78 + ) : (
79 + <input name="site" defaultValue={q.site ?? ''} placeholder="site slug or code" className={field} />
80 + )}
81 + </label>
82 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
83 + Country
84 + {countries.length ? (
85 + <select name="country" defaultValue={q.country ?? ''} className={field}>
86 + <option value="">Any</option>
87 + {countries.map((c) => (
88 + <option key={c} value={c}>{c}</option>
89 + ))}
90 + </select>
91 + ) : (
92 + <input name="country" defaultValue={q.country ?? ''} placeholder="ISO code" maxLength={2} className={field} />
93 + )}
94 + </label>
95 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
96 + Owner code
97 + <input name="owner" defaultValue={q.owner ?? ''} placeholder="US, CIS, PRC…" className={`${field} uppercase`} />
98 + </label>
99 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
100 + Min. payloads
101 + <input type="number" min={1} name="min_payloads" defaultValue={q.min_payloads ?? ''} className={field} />
102 + </label>
103 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
104 + After
105 + <input type="date" name="after" defaultValue={q.after ?? ''} className={field} />
106 + </label>
107 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
108 + Before
109 + <input type="date" name="before" defaultValue={q.before ?? ''} className={field} />
110 + </label>
111 + </div>
112 + <div className="mt-3 flex flex-wrap items-end gap-3">
113 + <label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-ink-3 sm:max-w-xs">
114 + Search
115 + <input type="search" name="q" defaultValue={q.q ?? ''} placeholder="Payload name or COSPAR launch id" className={field} />
116 + </label>
117 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
118 + Sort
119 + <select name="sort" defaultValue={q.sort ?? 'date'} className={field}>
120 + {LAUNCH_SORTS.map(([v, l]) => (
121 + <option key={v} value={v}>{l}</option>
122 + ))}
123 + </select>
124 + </label>
125 + <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-ink hover:brightness-110">Apply</button>
126 + {Object.keys(q).length > 0 && (
127 + <Link href={routes.launches()} className="inline-flex h-10 items-center rounded-md border border-rule px-3 text-sm text-ink-2 hover:bg-plane-2">Reset</Link>
128 + )}
129 + </div>
130 + </form>
131 + );
132 +}
added apps/web/src/components/launches/launch-timeline.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import { Bars, StackedBars } from '@/components/charts/charts';
2 +import { fmtInt, num } from '@/lib/format';
3 +import type { LaunchTimeline } from '@/lib/types';
4 +
5 +const REGION_LABELS = { us: 'United States', russia_cis: 'Russia / Kazakhstan', china: 'China', other: 'Other / unknown site' };
6 +
7 +/** Launches per year by launch-site region + monthly cadence for the last 36 months. Server-rendered SVG. */
8 +export function LaunchTimelineCharts({ timeline }: { timeline: LaunchTimeline }) {
9 + const years = timeline.years.map((y) => ({ x: String(y.year), us: num(y.us) ?? 0, russia_cis: num(y.russia_cis) ?? 0, china: num(y.china) ?? 0, other: num(y.other) ?? 0 }));
10 + const months = timeline.months.map((m) => ({ x: m.month.slice(0, 7), y: num(m.launches) ?? 0 }));
11 + const lastYear = timeline.years[timeline.years.length - 1];
12 + const totalLaunches = timeline.years.reduce((s, y) => s + (num(y.launches) ?? 0), 0);
13 + const totalPayloads = timeline.years.reduce((s, y) => s + (num(y.payloads) ?? 0), 0);
14 + return (
15 + <div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
16 + <div className="min-w-0">
17 + <div className="mb-2 flex items-baseline justify-between gap-3">
18 + <p className="eyebrow">Launches per year by launch-site region</p>
19 + <p className="tnum text-xs text-ink-3">{fmtInt(totalLaunches)} launches · {fmtInt(totalPayloads)} payloads since {timeline.years[0]?.year ?? '—'}</p>
20 + </div>
21 + <StackedBars data={years} keys={['us', 'russia_cis', 'china', 'other']} labels={REGION_LABELS} title="Orbital launches per year by launch-site region" height={220} xTicks={10} />
22 + </div>
23 + <div className="min-w-0">
24 + <div className="mb-2 flex items-baseline justify-between gap-3">
25 + <p className="eyebrow">Monthly cadence · last 36 months</p>
26 + {lastYear && <p className="tnum text-xs text-ink-3">{fmtInt(lastYear.launches)} launches in {lastYear.year}</p>}
27 + </div>
28 + <Bars data={months} title="Launches per month over the last 36 months" height={220} xTicks={6} highlightLast color="var(--series-2)" />
29 + <p className="mt-1 text-2xs text-ink-3">The last bar is the current month (partial).</p>
30 + </div>
31 + </div>
32 + );
33 +}
added apps/web/src/components/launches/launches-table.tsx +56 −0
@@ -0,0 +1,56 @@
1 +import Link from 'next/link';
2 +import { fmtDate, fmtInt, num } from '@/lib/format';
3 +import { routes } from '@/lib/site';
4 +import type { LaunchRow } from '@/lib/types';
5 +
6 +/** Launch rows (list page, site detail, launch detail siblings). `ownerHref` lets the caller scope owner chips to its context. */
7 +export function LaunchesTable({ rows, ownerHref = (code) => routes.launches(`owner=${encodeURIComponent(code)}`), showSite = true }: { rows: LaunchRow[]; ownerHref?: (code: string) => string; showSite?: boolean }) {
8 + return (
9 + <div className="overflow-x-auto scrollbar-thin">
10 + <table className="data-table stack">
11 + <thead>
12 + <tr>
13 + <th>Date</th>
14 + <th>COSPAR</th>
15 + <th>Primary payload</th>
16 + <th className="num">Payloads</th>
17 + <th className="num">Objects</th>
18 + <th className="num">On orbit</th>
19 + {showSite && <th>Site</th>}
20 + <th>Owners</th>
21 + </tr>
22 + </thead>
23 + <tbody>
24 + {rows.map((l) => {
25 + const owners = l.owner_codes ?? [];
26 + const onOrbit = num(l.on_orbit_count);
27 + const objects = num(l.object_count);
28 + return (
29 + <tr key={l.id}>
30 + <td data-label="Date" className="mono text-xs">{fmtDate(l.launch_date)}</td>
31 + <td data-label="COSPAR"><Link href={routes.launch(l.cospar_launch_id)} className="link mono text-xs">{l.cospar_launch_id}</Link></td>
32 + <td data-label="Primary payload" className="primary"><Link href={routes.launch(l.cospar_launch_id)} className="link font-medium">{l.primary_name ?? <span className="text-ink-3">unnamed</span>}</Link></td>
33 + <td data-label="Payloads" className="num mono text-xs">{fmtInt(l.payload_count)}</td>
34 + <td data-label="Objects" className="num mono text-xs">{fmtInt(l.object_count)}</td>
35 + <td data-label="On orbit" className={`num mono text-xs ${onOrbit === 0 && objects ? 'text-ink-3' : ''}`}>{fmtInt(l.on_orbit_count)}</td>
36 + {showSite && <td data-label="Site" className="text-xs">{l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? '—'}</td>}
37 + <td data-label="Owners" className="text-xs">
38 + {owners.length ? (
39 + <span className="flex flex-wrap gap-1">
40 + {owners.slice(0, 4).map((c) => (
41 + <Link key={c} href={ownerHref(c)} className="mono rounded border border-rule px-1.5 py-px text-[11px] text-ink-2 hover:border-rule-strong hover:text-ink">{c}</Link>
42 + ))}
43 + {owners.length > 4 && <span className="text-ink-3">+{owners.length - 4}</span>}
44 + </span>
45 + ) : (
46 + '—'
47 + )}
48 + </td>
49 + </tr>
50 + );
51 + })}
52 + </tbody>
53 + </table>
54 + </div>
55 + );
56 +}
added apps/web/src/components/launches/sites-map.tsx +38 −0
@@ -0,0 +1,38 @@
1 +import { WorldMap, type MapMarker } from '@/components/map/world-map';
2 +import { num } from '@/lib/format';
3 +import { routes } from '@/lib/site';
4 +import type { LaunchSiteRow } from '@/lib/types';
5 +
6 +/** All launch sites as markers sized by launch count (sqrt scale). Sites without coordinates are listed, not plotted. */
7 +export function SitesMap({ sites, highlight, labelTop = 8 }: { sites: LaunchSiteRow[]; highlight?: string; labelTop?: number }) {
8 + const plotted = sites.filter((s) => s.latitude !== null && s.longitude !== null);
9 + const max = Math.max(1, ...plotted.map((s) => num(s.launches) ?? 0));
10 + const ranked = [...plotted].sort((a, b) => (num(b.launches) ?? 0) - (num(a.launches) ?? 0));
11 + const labelled = new Set(ranked.slice(0, labelTop).map((s) => s.slug));
12 + const markers: MapMarker[] = ranked
13 + .reverse() // draw big ones first so small markers stay clickable on top
14 + .map((s) => {
15 + const n = num(s.launches) ?? 0;
16 + const active = s.slug === highlight;
17 + return {
18 + lat: s.latitude!,
19 + lon: s.longitude!,
20 + size: Math.max(2.5, 2.5 + Math.sqrt(n / max) * 11),
21 + color: active ? 'var(--accent)' : n >= max * 0.25 ? 'var(--series-4)' : 'var(--series-2)',
22 + href: routes.launchSite(s.slug),
23 + label: active || labelled.has(s.slug) ? s.name.replace(/\s*\(.*\)$/, '') : undefined,
24 + pulse: active,
25 + };
26 + });
27 + const missing = sites.length - plotted.length;
28 + return (
29 + <div>
30 + <div className="overflow-hidden rounded-lg border border-rule">
31 + <WorldMap markers={markers} title="Launch sites of the world, sized by number of launches" />
32 + </div>
33 + <p className="mt-2 text-2xs text-ink-3">
34 + Marker area ∝ launches. {plotted.length} sites plotted{missing > 0 ? `; ${missing} site${missing > 1 ? 's' : ''} without coordinates in SATCAT (listed below)` : ''}.
35 + </p>
36 + </div>
37 + );
38 +}
added apps/web/src/components/meta/connectors-table.tsx +146 −0
@@ -0,0 +1,146 @@
1 +import Link from 'next/link';
2 +import { FreshnessBadge } from '@/components/ui/badges';
3 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
4 +import type { ConnectorStatus } from '@/lib/types';
5 +
6 +/** "in 2 h" / "overdue 5 min" for scheduled timestamps (fmtAgo only handles the past). */
7 +export function fmtUntil(v: string | null | undefined, now: number = Date.now()): string {
8 + if (!v) return '—';
9 + const t = new Date(v).getTime();
10 + if (Number.isNaN(t)) return '—';
11 + const s = Math.round((t - now) / 1000);
12 + if (s <= 0) return s > -60 ? 'due now' : `overdue ${fmtAgo(v, now).replace(' ago', '')}`;
13 + if (s < 60) return 'in < 1 min';
14 + const m = Math.round(s / 60);
15 + if (m < 60) return `in ${m} min`;
16 + const h = Math.round(m / 60);
17 + if (h < 48) return `in ${h} h`;
18 + return `in ${Math.round(h / 24)} d`;
19 +}
20 +
21 +export function fmtDuration(ms: number | null | undefined): string {
22 + if (ms === null || ms === undefined) return '—';
23 + if (ms < 1000) return `${fmtInt(ms)} ms`;
24 + if (ms < 120_000) return `${(ms / 1000).toFixed(1)} s`;
25 + return `${(ms / 60_000).toFixed(1)} min`;
26 +}
27 +
28 +export function fmtInterval(s: number | null | undefined): string {
29 + if (!s) return '—';
30 + if (s % 86400 === 0) return `${s / 86400} d`;
31 + if (s % 3600 === 0) return `${s / 3600} h`;
32 + return `${Math.round(s / 60)} min`;
33 +}
34 +
35 +export function RunStatusPill({ status }: { status: string | null | undefined }) {
36 + const s = status ?? 'never run';
37 + const color = s === 'success' ? 'var(--active)' : s === 'failed' ? 'var(--danger)' : s === 'running' ? 'var(--accent)' : s === 'unchanged' ? 'var(--ink-2)' : 'var(--inactive)';
38 + return (
39 + <span className="mono inline-flex items-center gap-1.5 text-xs" style={{ color }}>
40 + <span className="dot" aria-hidden /> {s}
41 + </span>
42 + );
43 +}
44 +
45 +/**
46 + * Connector health table (public /status, /status/data, admin overview). Stacks into cards under 768 px.
47 + * `last_error` is the error of the most recent *failed* run, which may be older than the last success — labelled accordingly.
48 + */
49 +export function ConnectorsTable({ connectors, now = Date.now(), linkAdmin = false }: { connectors: ConnectorStatus[]; now?: number; linkAdmin?: boolean }) {
50 + return (
51 + <div className="overflow-x-auto">
52 + <table className="data-table stack min-w-0 md:min-w-[960px]">
53 + <thead>
54 + <tr>
55 + <th>Connector</th>
56 + <th>Freshness</th>
57 + <th>Last success</th>
58 + <th>Last status</th>
59 + <th>Next run</th>
60 + <th className="num">Duration</th>
61 + <th className="num">Failures</th>
62 + <th>Circuit</th>
63 + <th>Last failure</th>
64 + </tr>
65 + </thead>
66 + <tbody>
67 + {connectors.map((c) => (
68 + <tr key={c.name}>
69 + <td className="primary">
70 + {linkAdmin ? (
71 + <Link href={`/admin/connectors/${encodeURIComponent(c.name)}`} className="mono text-sm text-ink hover:text-accent">
72 + {c.name}
73 + </Link>
74 + ) : (
75 + <span className="mono text-sm text-ink">{c.name}</span>
76 + )}
77 + <div className="text-xs text-ink-3">
78 + {c.source_name} · every {fmtInterval(c.interval_seconds)}
79 + {!c.enabled && <span className="ml-2 text-warn">disabled</span>}
80 + </div>
81 + </td>
82 + <td data-label="Freshness">
83 + <FreshnessBadge status={c.freshness} />
84 + </td>
85 + <td data-label="Last success" title={fmtDateTime(c.last_success_at)}>
86 + <span className="text-sm">{fmtAgo(c.last_success_at, now)}</span>
87 + </td>
88 + <td data-label="Last status">
89 + <RunStatusPill status={c.last_status} />
90 + </td>
91 + <td data-label="Next run" title={fmtDateTime(c.next_run_at)}>
92 + <span className="text-sm text-ink-2">{c.enabled ? fmtUntil(c.next_run_at, now) : '—'}</span>
93 + </td>
94 + <td data-label="Duration" className="num tnum text-sm text-ink-2">
95 + {fmtDuration(c.last_duration_ms)}
96 + </td>
97 + <td data-label="Failures" className="num tnum text-sm">
98 + <span className={c.consecutive_failures > 0 ? 'text-warn' : 'text-ink-2'}>{fmtInt(c.consecutive_failures)}</span>
99 + </td>
100 + <td data-label="Circuit">
101 + {c.circuit_open_until && new Date(c.circuit_open_until).getTime() > now ? (
102 + <span className="text-xs text-danger" title={fmtDateTime(c.circuit_open_until)}>
103 + open · resets {fmtUntil(c.circuit_open_until, now)}
104 + </span>
105 + ) : (
106 + <span className="text-xs text-ink-3">closed</span>
107 + )}
108 + </td>
109 + <td data-label="Last failure" className="max-w-[280px]">
110 + {c.last_error ? (
111 + <span className="mono block truncate text-xs text-ink-3" title={c.last_error}>
112 + {c.last_error.length > 80 ? `${c.last_error.slice(0, 80)}…` : c.last_error}
113 + </span>
114 + ) : (
115 + <span className="text-xs text-ink-3">none recorded</span>
116 + )}
117 + </td>
118 + </tr>
119 + ))}
120 + </tbody>
121 + </table>
122 + </div>
123 + );
124 +}
125 +
126 +/** Freshness thresholds legend: per-connector thresholds scale with the connector interval (misc.py: aging = 3×, stale = 12×). */
127 +export function FreshnessLegend({ connectors }: { connectors?: ConnectorStatus[] }) {
128 + return (
129 + <div className="text-sm leading-relaxed text-ink-2">
130 + <p>
131 + Connector freshness is relative to each connector&rsquo;s own schedule: <FreshnessBadge status="fresh" label="fresh" /> when the last successful sync is younger than 3 × the interval,{' '}
132 + <FreshnessBadge status="aging" label="aging" /> up to 12 × the interval, <FreshnessBadge status="stale" label="stale" /> beyond that, <FreshnessBadge status="unavailable" label="unavailable" /> when it never succeeded and{' '}
133 + <FreshnessBadge status="not_enabled" label="not enabled" /> for planned connectors.
134 + </p>
135 + {connectors && connectors.length > 0 && (
136 + <ul className="mt-3 grid gap-1 text-xs text-ink-3 sm:grid-cols-2">
137 + {connectors.map((c) => (
138 + <li key={c.name} className="mono">
139 + {c.name}: every {fmtInterval(c.interval_seconds)} → aging after {fmtInterval(c.interval_seconds * 3)}, stale after {fmtInterval(c.interval_seconds * 12)}
140 + </li>
141 + ))}
142 + </ul>
143 + )}
144 + </div>
145 + );
146 +}
added apps/web/src/components/meta/endpoints.ts +88 −0
@@ -0,0 +1,88 @@
1 +/** Public API catalogue for /developers — mirrors the FastAPI routers (verified against /api/v1/openapi.json). */
2 +
3 +export interface EndpointDoc {
4 + method: 'GET' | 'POST';
5 + path: string;
6 + summary: string;
7 + params?: string[];
8 + example: string;
9 + bucket?: 'search' | 'positions' | 'position';
10 +}
11 +
12 +export const ENDPOINT_GROUPS: { title: string; endpoints: EndpointDoc[] }[] = [
13 + {
14 + title: 'Satellites',
15 + endpoints: [
16 + { method: 'GET', path: '/satellites', summary: 'Paginated catalog with filters (status, object_type, orbit_class, mission_type, country, operator, constellation, launch, launch_site, on_orbit, has_gp, launched_after/before, decayed_after, min/max_perigee, tag, q, sort).', params: ['page', 'page_size', 'sort', '…'], example: '/satellites?status=ACTIVE&orbit_class=LEO&sort=-launch_date&page_size=25' },
17 + { method: 'GET', path: '/satellites/facets', summary: 'Facet counts (status, object type, orbit class, mission type, country, constellation) for the same filters.', example: '/satellites/facets?constellation=starlink' },
18 + { method: 'GET', path: '/satellites/{ident}', summary: 'One object by slug, NORAD id or COSPAR id — canonical record, latest elements, live position, provenance, freshness, events, siblings.', example: '/satellites/25544' },
19 + { method: 'GET', path: '/satellites/{ident}/position', summary: 'Geodetic position (lat, lon, altitude, velocity) at `time` (default now), propagated on demand with SGP4.', params: ['time'], example: '/satellites/25544/position', bucket: 'position' },
20 + { method: 'GET', path: '/satellites/{ident}/live', summary: 'Lightweight live position for polling UIs (30 s cache).', example: '/satellites/25544/live', bucket: 'position' },
21 + { method: 'GET', path: '/satellites/{ident}/track', summary: 'Ground track: points `before`/`after` now (minutes) every `step` seconds, with a `future` flag.', params: ['before', 'after', 'step'], example: '/satellites/25544/track?before=45&after=90&step=60' },
22 + { method: 'GET', path: '/satellites/{ident}/orbit', summary: 'Orbital element history (append-only), newest first.', params: ['limit'], example: '/satellites/25544/orbit?limit=50' },
23 + { method: 'GET', path: '/satellites/{ident}/history', summary: 'Field change log (status, operator, orbit class…) plus a daily altitude/period/inclination series.', example: '/satellites/25544/history' },
24 + ],
25 + },
26 + {
27 + title: 'Search',
28 + endpoints: [
29 + { method: 'GET', path: '/search', summary: 'Full-text search across satellites, operators, constellations, countries, launches and launch sites; returns ranked results with hrefs and filter shortcuts.', params: ['q', 'limit', 'types'], example: '/search?q=starlink&limit=10', bucket: 'search' },
30 + { method: 'GET', path: '/search/suggest', summary: 'Typeahead suggestions.', params: ['q'], example: '/search/suggest?q=sent', bucket: 'search' },
31 + ],
32 + },
33 + {
34 + title: 'Entities',
35 + endpoints: [
36 + { method: 'GET', path: '/constellations', summary: 'Constellations with fleet counts, growth and activity score.', params: ['service', 'orbit', 'sort', 'page', 'page_size'], example: '/constellations?sort=-active' },
37 + { method: 'GET', path: '/constellations/{slug}', summary: 'Constellation detail: shells, histograms, launches, memberships, match patterns and CelesTrak groups used.', example: '/constellations/starlink' },
38 + { method: 'GET', path: '/operators', summary: 'Operators / organizations with payload counts.', params: ['kind', 'country', 'sort', 'q', 'page', 'page_size'], example: '/operators?sort=-active_payloads' },
39 + { method: 'GET', path: '/operators/{slug}', summary: 'Operator detail: fleet, constellations, launches, distributions.', example: '/operators/spacex' },
40 + { method: 'GET', path: '/countries', summary: 'Countries ranked by active payloads, on-orbit objects or debris.', params: ['sort'], example: '/countries?sort=active' },
41 + { method: 'GET', path: '/countries/{ident}', summary: 'Country detail by ISO code or slug.', example: '/countries/CA' },
42 + ],
43 + },
44 + {
45 + title: 'Launches, debris, reentries',
46 + endpoints: [
47 + { method: 'GET', path: '/launches', summary: 'Launches derived from international designators, with payload/object counts.', params: ['year', 'site', 'country', 'owner', 'min_payloads', 'after', 'before', 'q', 'sort', 'page', 'page_size'], example: '/launches?year=2026&sort=-launch_date' },
48 + { method: 'GET', path: '/launches/timeline', summary: 'Launches and payloads per year and month, plus launch sites.', example: '/launches/timeline' },
49 + { method: 'GET', path: '/launches/{cospar}', summary: 'One launch by designator prefix (YYYY-NNN) with all catalogued objects.', example: '/launches/1998-067' },
50 + { method: 'GET', path: '/launch-sites', summary: 'Launch sites with coordinates and counts.', example: '/launch-sites' },
51 + { method: 'GET', path: '/launch-sites/{slug}', summary: 'Launch site detail with yearly series and recent launches.', example: '/launch-sites/kennedy-space-center' },
52 + { method: 'GET', path: '/debris', summary: 'Debris and rocket-body totals by country, altitude, launch; growth and decay series.', example: '/debris' },
53 + { method: 'GET', path: '/reentries', summary: 'Recently decayed objects (paginated), monthly series and a low-perigee watch list.', params: ['object_type', 'days', 'page', 'page_size'], example: '/reentries?days=30' },
54 + ],
55 + },
56 + {
57 + title: 'Events and statistics',
58 + endpoints: [
59 + { method: 'GET', path: '/events', summary: 'Detected events (launch catalogued, decay, status change, orbit change…) with linked entities.', params: ['type', 'since', 'entity', 'page', 'page_size'], example: '/events?type=DECAY&page_size=20' },
60 + { method: 'GET', path: '/events/{event_id}', summary: 'One event.', example: '/events/evt_…' },
61 + { method: 'GET', path: '/stats', summary: 'Latest global statistics snapshot: totals, by orbit class / object type / mission, yearly series, top entities, orbital buckets, connector state.', example: '/stats' },
62 + { method: 'GET', path: '/rankings', summary: 'Ranked lists by metric (e.g. active_payloads, debris, launches_365d).', params: ['metric', 'limit'], example: '/rankings?metric=active_payloads&limit=20' },
63 + { method: 'GET', path: '/orbit/density', summary: 'Orbital density: objects per perigee bucket, 25 km LEO profile, 5° inclination profile. Informational, not a risk metric.', example: '/orbit/density' },
64 + ],
65 + },
66 + {
67 + title: 'Orbit (batch)',
68 + endpoints: [
69 + { method: 'GET', path: '/orbit/positions', summary: 'Positions of every tracked object at t0 and t0+step, as compact parallel arrays (see below).', params: ['t', 'step'], example: '/orbit/positions?step=60', bucket: 'positions' },
70 + ],
71 + },
72 + {
73 + title: 'Transparency',
74 + endpoints: [
75 + { method: 'GET', path: '/sources', summary: 'Source catalog with license, attribution, connectors, snapshot and provenance counts, freshness.', example: '/sources' },
76 + { method: 'GET', path: '/sources/status', summary: 'Connector health (last success, schedule, failures, circuit breaker) and orbital element age.', example: '/sources/status' },
77 + { method: 'GET', path: '/methodology', summary: 'Versioned metric definitions, constellation rules and owner-code table.', example: '/methodology' },
78 + { method: 'GET', path: '/health', summary: 'Liveness + component checks (also at /health without the prefix).', example: '/health' },
79 + ],
80 + },
81 +];
82 +
83 +export const RATE_LIMITS: { bucket: string; perMinute: number; applies: string }[] = [
84 + { bucket: 'default', perMinute: 600, applies: 'every endpoint not listed below' },
85 + { bucket: 'search', perMinute: 120, applies: '/search, /search/suggest' },
86 + { bucket: 'position', perMinute: 240, applies: '/satellites/{ident}/position, /live' },
87 + { bucket: 'positions', perMinute: 60, applies: '/orbit/positions (batch)' },
88 +];
added apps/web/src/components/meta/legal.ts +24 −0
@@ -0,0 +1,24 @@
1 +/**
2 + * Legal / attribution wording shared by /methodology, /terms, /developers, /about.
3 + * The disclaimer and attribution sentences are the exact texts used in the site footer (see CLAUDE.md) — do not paraphrase.
4 + */
5 +
6 +export const DISCLAIMER =
7 + 'SatelliteIndex.io is an informational platform. Orbital positions, predictions, conjunction information, reentry predictions, and derived metrics may contain delays or uncertainty and must not be used as the sole source for safety-critical, navigation, mission-control, military, or operational decisions.';
8 +
9 +export const ATTRIBUTION =
10 + 'SatelliteIndex aggregates public and licensed orbital, governmental, scientific, and operator data from multiple sources. Orbital data courtesy of CelesTrak. Derived classifications (constellation membership, orbit class, mission type) follow the published methodology.';
11 +
12 +export const MISSION =
13 + 'The definitive public intelligence layer for everything operating, launched, proposed, licensed, decaying or changing in Earth orbit — Bloomberg Terminal × FlightRadar24 × Crunchbase for orbital infrastructure. Not a simple satellite tracker.';
14 +
15 +/** Field → source priority, as implemented in the connectors (CelesTrak GP for elements, SATCAT for catalog, SatelliteIndex for derived). */
16 +export const FIELD_PRIORITY: { field: string; source: string; sourceId: string; note: string }[] = [
17 + { field: 'Orbital elements (mean motion, eccentricity, inclination, RAAN, argument of perigee, mean anomaly, B*)', source: 'CelesTrak GP', sourceId: 'celestrak', note: 'OMM element sets from the `active` GP group; every epoch is appended to the orbital history, the latest becomes the orbital state.' },
18 + { field: 'Catalog identity (NORAD, COSPAR, object name, object type)', source: 'CelesTrak SATCAT', sourceId: 'celestrak_satcat', note: 'Full catalog snapshot, diffed against the canonical table on every run.' },
19 + { field: 'Status (operational status code), decay date', source: 'CelesTrak SATCAT', sourceId: 'celestrak_satcat', note: 'OPS_STATUS_CODE mapped to the canonical status; a decay date always wins.' },
20 + { field: 'Owner code, launch date, launch site', source: 'CelesTrak SATCAT', sourceId: 'celestrak_satcat', note: 'Owner codes are resolved to countries and organizations with the curated owner-code table.' },
21 + { field: 'Launches (international designator prefix YYYY-NNN)', source: 'Derived from SATCAT', sourceId: 'satelliteindex', note: 'Grouped from the COSPAR designator of the catalogued objects; counts refreshed after each SATCAT run.' },
22 + { field: 'Constellation membership, tags', source: 'CelesTrak GP groups, then SatelliteIndex name patterns', sourceId: 'celestrak', note: 'A group membership is source-backed; a regular-expression match on the name is derived.' },
23 + { field: 'Orbit class, mission type, operator/country from owner code, activity score, orbital density', source: 'SatelliteIndex (derived / curated)', sourceId: 'satelliteindex', note: 'Versioned metric definitions — see the methodology page.' },
24 +];
added apps/web/src/components/meta/methodology-sections.tsx +245 −0
@@ -0,0 +1,245 @@
1 +import Link from 'next/link';
2 +import { Callout, Code, DocSection, Prose, SubHeading } from '@/components/meta/prose';
3 +import { routes } from '@/lib/site';
4 +
5 +/**
6 + * Static prose sections of /methodology. Facts come from the backend implementation:
7 + * services/resolution.py, orbital/propagate.py, orbital/elements.py, registry/reference.py (SATCAT_STATUS), services/classify.py,
8 + * connectors/orbital/celestrak/satcat.py (_ensure_launches), api/common.py (freshness_status), config.py.
9 + */
10 +
11 +export function PipelineSection() {
12 + return (
13 + <DocSection id="pipeline" eyebrow="01" title="Data pipeline">
14 + <Prose>
15 + <p>The site is never coupled to a third-party feed. Every value passes through the same chain, and each stage keeps what the previous one produced:</p>
16 + <ol>
17 + <li>
18 + <strong>Connector</strong> — a scheduled worker per upstream feed (<code>celestrak_gp</code>, <code>celestrak_groups</code>, <code>celestrak_satcat</code>, <code>derived_analytics</code>). Each run is recorded with duration, record counts, a payload hash and any error; repeated failures open a circuit breaker.
19 + </li>
20 + <li>
21 + <strong>Raw snapshot</strong> — the exact upstream payload is stored gzip-compressed on disk and indexed in <code>raw_records</code> before anything is parsed. Only payloads whose hash changed are processed; unchanged responses are logged as such.
22 + </li>
23 + <li>
24 + <strong>Normalize</strong> — rows are parsed into a common shape (identifiers, names, dates, element sets). Suspiciously short responses (for example a catalog with far fewer than 50 000 rows) are rejected as truncated rather than treated as “no data”.
25 + </li>
26 + <li>
27 + <strong>Entity resolution</strong> — each normalized row is matched to an existing canonical object (next section) or creates a new one. Ambiguous matches go to a manual review queue.
28 + </li>
29 + <li>
30 + <strong>Canonical store</strong> — one row per object in <code>satellites</code>, an append-only <code>orbital_elements</code> history with <code>orbital_state</code> pointing at the latest epoch, field-level history and <code>field_provenance</code> (source, observation time, confidence) for every accepted value. Objects missing from an upstream response are <em>never</em> deleted.
31 + </li>
32 + <li>
33 + <strong>Derived</strong> — materialized statistics, constellation/operator/country aggregates, the search index and detected events are refreshed by <code>derived_analytics</code> (hourly) using the versioned metric definitions below.
34 + </li>
35 + <li>
36 + <strong>API → web</strong> — the public JSON API reads only the canonical and derived layers; the website is a client of that API. Positions are computed at request time (see orbit calculation).
37 + </li>
38 + </ol>
39 + </Prose>
40 + </DocSection>
41 + );
42 +}
43 +
44 +export function ResolutionSection() {
45 + return (
46 + <DocSection id="entity-resolution" eyebrow="03" title="Entity resolution">
47 + <Prose>
48 + <p>Matching an incoming row to a canonical object follows a strict priority; the first rule that yields exactly one candidate wins:</p>
49 + <ol>
50 + <li>
51 + <strong>NORAD catalog number</strong> — if the row has a NORAD id already known, it is the same object.
52 + </li>
53 + <li>
54 + <strong>COSPAR designator</strong> — only when the row has no NORAD id, and only if exactly one canonical object carries that designator.
55 + </li>
56 + <li>
57 + <strong>Exact normalized name</strong> — only when the row has no NORAD id, against canonical objects that also lack one, and only if the name is unique.
58 + </li>
59 + <li>
60 + <strong>Create</strong> — otherwise a new canonical object is created with a ULID and a slug (<code>name-norad</code>, de-duplicated with a numeric suffix).
61 + </li>
62 + </ol>
63 + <p>
64 + Anything ambiguous — a COSPAR id that maps to several objects, a name shared by several objects, or a NORAD id that appears alongside a different existing designator — is <strong>never merged automatically</strong>. It is written to <code>manual_review_queue</code> with both candidates and a confidence; an operator decides <em>merge</em>, <em>keep separate</em> or <em>dismiss</em>. Merges move aliases, identifiers and element history to the kept object and are journaled in <code>entity_merges</code> with a snapshot of the removed row, so they can be audited or reversed.
65 + </p>
66 + </Prose>
67 + </DocSection>
68 + );
69 +}
70 +
71 +export function OrbitCalcSection() {
72 + return (
73 + <DocSection id="orbit-calculation" eyebrow="04" title="Orbit calculation">
74 + <Prose>
75 + <p>
76 + Positions are propagated with <strong>SGP4</strong> (the <code>python-sgp4</code> implementation of the Vallado et al. reference code, vectorised with <code>SatrecArray</code>) using the <strong>WGS-72</strong> gravity constants — the model the element sets were fitted against. Inputs are the OMM element sets from CelesTrak (epoch, mean motion, eccentricity, inclination, RAAN, argument of perigee, mean anomaly, B*, first derivative of mean motion).
77 + </p>
78 + <p>
79 + SGP4 yields a position vector in the <strong>TEME</strong> frame. It is rotated to Earth-fixed coordinates (ECEF) about the Z axis by the <strong>Greenwich Mean Sidereal Time</strong> of the requested instant (UT1 ≈ UTC; polar motion ignored, ≈ 10 m), then converted to geodetic latitude, longitude and altitude on the <strong>WGS84</strong> ellipsoid by iteration. Velocity is the norm of the TEME velocity vector.
80 + </p>
81 + <p>
82 + Derived geometry shown on satellite pages comes from the same element set: semi-major axis <em>a</em> = (μ / n²)^1/3 with μ = 398 600.4418 km³/s², perigee/apogee = a(1 ∓ e) − 6 378.137 km, period = 1440 / n minutes.
83 + </p>
84 + </Prose>
85 + <SubHeading>Accuracy</SubHeading>
86 + <Prose>
87 + <p>
88 + SGP4 is an analytical mean-element model: typical along-track error is of the order of a kilometre at epoch and grows by kilometres per day, faster for low, high-drag orbits and after manoeuvres. Positions shown on this site are therefore <strong>indicative</strong>: good for “where is it over the Earth right now”, not for pointing, conjunction or reentry work. Every position carries the epoch it was propagated from and its age.
89 + </p>
90 + <p>
91 + Positions are <strong>computed on demand and never stored</strong>: the propagator keeps the latest element set of every object in memory and answers batch and single-object requests with a 30-second cache. Only element sets are persisted.
92 + </p>
93 + </Prose>
94 + </DocSection>
95 + );
96 +}
97 +
98 +const OPS_CODES: { code: string; meaning: string; status: string }[] = [
99 + { code: '+', meaning: 'Operational', status: 'ACTIVE' },
100 + { code: 'P', meaning: 'Partially operational', status: 'ACTIVE' },
101 + { code: 'B', meaning: 'Backup / standby', status: 'INACTIVE' },
102 + { code: 'S', meaning: 'Spare', status: 'INACTIVE' },
103 + { code: 'X', meaning: 'Extended mission', status: 'INACTIVE' },
104 + { code: 'D', meaning: 'Decayed', status: 'DECAYED' },
105 + { code: '?', meaning: 'Unknown', status: 'UNKNOWN' },
106 + { code: '(blank)', meaning: 'No code published', status: 'UNKNOWN' },
107 +];
108 +
109 +export function StatusSection({ methodologyText }: { methodologyText: string | null }) {
110 + return (
111 + <DocSection id="status" eyebrow="05" title="Status classification">
112 + {methodologyText && <Callout className="mt-0 mb-4">{methodologyText}</Callout>}
113 + <div className="overflow-x-auto">
114 + <table className="data-table sm:max-w-lg">
115 + <thead>
116 + <tr>
117 + <th>SATCAT code</th>
118 + <th>Meaning</th>
119 + <th>Canonical status</th>
120 + </tr>
121 + </thead>
122 + <tbody>
123 + {OPS_CODES.map((c) => (
124 + <tr key={c.code}>
125 + <td className="mono text-sm">{c.code}</td>
126 + <td className="text-sm text-ink-2">{c.meaning}</td>
127 + <td className="mono text-xs">{c.status}</td>
128 + </tr>
129 + ))}
130 + </tbody>
131 + </table>
132 + </div>
133 + <Prose className="mt-4">
134 + <p>
135 + Two overrides apply after the code lookup, in this order: a <strong>decay date</strong> always produces <code>DECAYED</code>; debris and rocket bodies with no code are <code>INACTIVE</code> rather than <code>UNKNOWN</code> (they cannot be “operational”). Payloads present in the CelesTrak <code>active</code> GP group but lacking a SATCAT code are considered <code>ACTIVE</code>. Status changes emit <em>decommission</em> / <em>activation</em> events and are kept in the field history.
136 + </p>
137 + </Prose>
138 + </DocSection>
139 + );
140 +}
141 +
142 +export function OrbitClassSection({ methodologyText }: { methodologyText: string | null }) {
143 + return (
144 + <DocSection id="orbit-class" eyebrow="06" title="Orbit class">
145 + {methodologyText && <Callout className="mt-0 mb-4">{methodologyText}</Callout>}
146 + <Prose>
147 + <p>The rule is evaluated on the latest element set (or, for objects without GP data, on the SATCAT apsides with eccentricity estimated from perigee and apogee). Implementation order, as in <code>orbital/elements.py</code>:</p>
148 + </Prose>
149 + <Code label="classify_orbit">{`if apogee or perigee missing → OTHER
150 +if |period − 1436.07 min| ≤ 30 and e < 0.05 and i < 20° → GEO
151 +if e > 0.25 and apogee > 35 000 km → HEO
152 +if apogee < 2 000 km → LEO
153 +if perigee ≥ 2 000 km and apogee < 35 786 + 2 000 km:
154 + if |period − 1436.07| ≤ 60 → GEO (inclined / drifting geosynchronous)
155 + else → MEO
156 +if |period − 1436.07| ≤ 60 and e < 0.1 → GEO
157 +else → OTHER`}</Code>
158 + <Prose className="mt-3">
159 + <p>
160 + <code>OTHER</code> therefore collects transfer orbits, highly eccentric non-HEO objects and anything with an apogee below 2 000 km but a perigee above it — impossible by definition, so effectively it is the “does not fit” bucket. Orbit class is a <em>derived</em> label, versioned as metric <code>orbit_class</code>.
161 + </p>
162 + </Prose>
163 + </DocSection>
164 + );
165 +}
166 +
167 +export function MissionLaunchSection() {
168 + return (
169 + <>
170 + <DocSection id="mission-type" eyebrow="08" title="Mission type">
171 + <Prose>
172 + <p>Mission type is derived, in this order of preference (<code>services/classify.py</code>):</p>
173 + <ol>
174 + <li>the <strong>service type of the matched constellation</strong> (communications, earth-observation, navigation, iot…);</li>
175 + <li>
176 + otherwise the <strong>object type</strong> when it is decisive: rocket bodies → <code>rocket-body</code>, debris → <code>debris</code>, stations → <code>station</code>;
177 + </li>
178 + <li>
179 + otherwise a <strong>documented name pattern</strong> from the curated registry (for example weather, GNSS or science families);
180 + </li>
181 + <li>
182 + otherwise <code>unknown</code>. A later run never downgrades a known mission type to unknown.
183 + </li>
184 + </ol>
185 + </Prose>
186 + </DocSection>
187 + <DocSection id="launches" eyebrow="09" title="Launches">
188 + <Prose>
189 + <p>
190 + No launch feed is ingested yet. Launches are <strong>derived from international designators</strong>: the first eight characters of a COSPAR id (<code>YYYY-NNN</code>) identify the launch, so every object sharing that prefix is attached to one launch row. The launch date and site are the earliest date and the site reported for those objects in SATCAT; the “primary” payload is the object whose piece letter is <code>A</code>; payload, object and on-orbit counts are recomputed after every catalog run. Objects with no COSPAR id (analyst objects) belong to no launch and are flagged.
191 + </p>
192 + </Prose>
193 + </DocSection>
194 + </>
195 + );
196 +}
197 +
198 +export function FreshnessSection({ methodologyText }: { methodologyText: string | null }) {
199 + return (
200 + <DocSection id="freshness" eyebrow="12" title="Freshness thresholds">
201 + {methodologyText && <Callout className="mt-0 mb-4">{methodologyText}</Callout>}
202 + <Prose>
203 + <p>Two scales are in use, and both are shown rather than hidden behind an average:</p>
204 + <ul>
205 + <li>
206 + <strong>Per connector</strong> (<Link href={routes.statusData()} className="link">/status/data</Link>, <Link href={routes.sources()} className="link">/sources</Link>): relative to the connector&rsquo;s own interval — <em>fresh</em> under 3 × interval, <em>aging</em> under 12 ×, <em>stale</em> beyond; <em>unavailable</em> if it never succeeded, <em>not enabled</em> for planned connectors.
207 + </li>
208 + <li>
209 + <strong>Per object</strong> (satellite pages): the orbit is <em>fresh</em> under 12 h, <em>aging</em> from 12 to 48 h and <em>stale</em> after 48 h since the element epoch or the last successful orbital sync; catalog metadata uses 36 h / 96 h.
210 + </li>
211 + </ul>
212 + <p>The health endpoint reports the worst connector freshness as the platform&rsquo;s data status.</p>
213 + </Prose>
214 + </DocSection>
215 + );
216 +}
217 +
218 +export function LimitationsSection() {
219 + return (
220 + <DocSection id="limitations" eyebrow="13" title="Limitations">
221 + <Prose>
222 + <ul>
223 + <li>
224 + <strong>Name-pattern classification errors.</strong> Constellation membership and mission type inferred from names can misfile objects with unusual or reused names (test payloads, renamed spacecraft, shared bus names). Group-backed memberships are more reliable than pattern-backed ones; both are labelled.
225 + </li>
226 + <li>
227 + <strong>Catalog latency.</strong> SATCAT status codes, decay dates and new-object entries lag reality by hours to weeks. A satellite can be operational before it is marked <code>+</code>, and decayed objects may stay “on orbit” until the catalog catches up.
228 + </li>
229 + <li>
230 + <strong>GEO drift and graveyard objects.</strong> Inclined, drifting or super-synchronous objects sit near the GEO/MEO/OTHER boundaries and may switch class between element sets.
231 + </li>
232 + <li>
233 + <strong>Analyst objects.</strong> Objects tracked without a COSPAR designator (and sometimes with temporary NORAD numbers) have no launch, owner or country and can later be re-identified — that is exactly the case the manual review queue exists for.
234 + </li>
235 + <li>
236 + <strong>Element-set age.</strong> Positions inherit the age of their elements; a stale feed means every live position on the site is stale, and the status page says so.
237 + </li>
238 + <li>
239 + <strong>Coverage.</strong> Only CelesTrak feeds are ingested today; government registries (Space-Track, UNOOSA), ESA DISCOS and GCAT are registered as planned sources but contribute nothing yet.
240 + </li>
241 + </ul>
242 + </Prose>
243 + </DocSection>
244 + );
245 +}
added apps/web/src/components/meta/methodology-tables.tsx +182 −0
@@ -0,0 +1,182 @@
1 +import Link from 'next/link';
2 +import { Callout, DocSection, Prose } from '@/components/meta/prose';
3 +import { Unavailable } from '@/components/ui/unavailable';
4 +import { fmtDateTime, fmtInt } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import type { MethodologyPayload } from '@/lib/types';
7 +
8 +/** Data-driven sections of /methodology rendered from `api.methodology()`: constellation rules, owner codes, metric definitions. */
9 +
10 +export function ConstellationRulesSection({ rules }: { rules: MethodologyPayload['constellation_rules'] | null }) {
11 + return (
12 + <DocSection id="constellations" eyebrow="07" title="Constellation membership">
13 + <Prose>
14 + <p>
15 + Membership is assigned in two tiers. <strong>CelesTrak GP groups first</strong>: an object listed in a thematic group mapped to a constellation (for example <code>starlink</code>, <code>oneweb</code>, <code>kuiper</code>) is a source-backed member (<em>method: celestrak_group</em>). <strong>Documented name patterns second</strong>: payloads not covered by a group are matched against the regular expressions of the curated registry (<em>method: name_pattern</em>, derived). Membership history is kept with <code>since</code>/<code>until</code> dates; a member is never removed just because a group response was short.
16 + </p>
17 + <p>The table below is the live registry — the exact patterns and groups the classifier uses right now.</p>
18 + </Prose>
19 + {rules === null ? (
20 + <Unavailable what="Constellation rules" className="mt-4" />
21 + ) : (
22 + <div className="mt-4 overflow-x-auto">
23 + <table className="data-table stack md:min-w-[720px]">
24 + <thead>
25 + <tr>
26 + <th>Constellation</th>
27 + <th>Service</th>
28 + <th>CelesTrak groups</th>
29 + <th>Name patterns</th>
30 + </tr>
31 + </thead>
32 + <tbody>
33 + {rules.map((r) => (
34 + <tr key={r.slug}>
35 + <td className="primary">
36 + <Link href={routes.constellation(r.slug)} className="text-sm text-ink hover:text-accent">
37 + {r.name}
38 + </Link>
39 + </td>
40 + <td data-label="Service" className="text-sm text-ink-2">
41 + {r.service_type ?? '—'}
42 + </td>
43 + <td data-label="CelesTrak groups">
44 + {r.celestrak_groups.length === 0 ? (
45 + <span className="text-xs text-ink-3">none</span>
46 + ) : (
47 + <span className="flex flex-wrap gap-1">
48 + {r.celestrak_groups.map((g) => (
49 + <code key={g} className="mono rounded bg-accent-soft px-1.5 py-0.5 text-[11px] text-accent">
50 + {g}
51 + </code>
52 + ))}
53 + </span>
54 + )}
55 + </td>
56 + <td data-label="Name patterns">
57 + {r.match_patterns.length === 0 ? (
58 + <span className="text-xs text-ink-3">none</span>
59 + ) : (
60 + <span className="flex flex-wrap gap-1">
61 + {r.match_patterns.map((p) => (
62 + <code key={p} className="mono rounded bg-plane-2 px-1.5 py-0.5 text-[11px] text-ink">
63 + {p}
64 + </code>
65 + ))}
66 + </span>
67 + )}
68 + </td>
69 + </tr>
70 + ))}
71 + </tbody>
72 + </table>
73 + <p className="mt-2 text-xs text-ink-3">{fmtInt(rules.length)} constellations in the registry.</p>
74 + </div>
75 + )}
76 + </DocSection>
77 + );
78 +}
79 +
80 +const KIND_LABELS: Record<string, string> = { country: 'Countries', organization: 'Organizations', joint: 'Joint programmes', intergovernmental: 'Intergovernmental', agency: 'Agencies', unknown: 'Unknown / unassigned' };
81 +const KIND_ORDER = ['country', 'intergovernmental', 'agency', 'organization', 'joint', 'unknown'];
82 +
83 +export function OwnerCodesSection({ owners }: { owners: MethodologyPayload['owner_codes'] | null }) {
84 + const groups = new Map<string, MethodologyPayload['owner_codes']>();
85 + for (const o of owners ?? []) {
86 + const list = groups.get(o.kind) ?? [];
87 + list.push(o);
88 + groups.set(o.kind, list);
89 + }
90 + const kinds = [...groups.keys()].sort((a, b) => (KIND_ORDER.indexOf(a) === -1 ? 99 : KIND_ORDER.indexOf(a)) - (KIND_ORDER.indexOf(b) === -1 ? 99 : KIND_ORDER.indexOf(b)));
91 + return (
92 + <DocSection id="owner-codes" eyebrow="10" title="Owner codes">
93 + <Prose>
94 + <p>
95 + SATCAT identifies the responsible party of every object with a short <strong>owner code</strong>. We map each code to a country (ISO 3166) and, when the owner is an organization, agency or intergovernmental body, to an operator entity. Joint programmes map to several countries&rsquo; programmes but a single primary country when one is defined. Unmapped codes raise an <code>UNKNOWN_COUNTRY</code> quality flag instead of guessing.
96 + </p>
97 + </Prose>
98 + {owners === null ? (
99 + <Unavailable what="Owner codes" className="mt-4" />
100 + ) : (
101 + <div className="mt-4 space-y-2">
102 + {kinds.map((kind) => {
103 + const rows = groups.get(kind) ?? [];
104 + return (
105 + <details key={kind} className="group rounded-md border border-rule" open={kind === 'intergovernmental'}>
106 + <summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 px-3 text-sm hover:bg-plane-2">
107 + <span className="font-medium">{KIND_LABELS[kind] ?? kind}</span>
108 + <span className="tnum text-xs text-ink-3">{fmtInt(rows.length)} codes</span>
109 + </summary>
110 + <div className="overflow-x-auto border-t border-rule">
111 + <table className="data-table">
112 + <thead>
113 + <tr>
114 + <th>Code</th>
115 + <th>Name</th>
116 + <th>Country</th>
117 + </tr>
118 + </thead>
119 + <tbody>
120 + {rows.map((o) => (
121 + <tr key={o.code}>
122 + <td className="mono text-sm">{o.code}</td>
123 + <td className="text-sm text-ink-2">{o.name}</td>
124 + <td className="mono text-xs text-ink-3">{o.country_code ?? '—'}</td>
125 + </tr>
126 + ))}
127 + </tbody>
128 + </table>
129 + </div>
130 + </details>
131 + );
132 + })}
133 + <p className="text-xs text-ink-3">{fmtInt(owners.length)} owner codes mapped.</p>
134 + </div>
135 + )}
136 + </DocSection>
137 + );
138 +}
139 +
140 +export function MetricsSection({ metrics }: { metrics: MethodologyPayload['metrics'] | null }) {
141 + return (
142 + <DocSection id="metrics" eyebrow="11" title="Derived metrics">
143 + <Prose>
144 + <p>
145 + Every derived value on the site points at one of these definitions. The version changes whenever the rule changes; the inputs list the exact canonical columns the rule reads. None of them is a safety metric.
146 + </p>
147 + </Prose>
148 + {metrics === null ? (
149 + <Unavailable what="Metric definitions" className="mt-4" />
150 + ) : metrics.length === 0 ? (
151 + <Callout>No metric definitions are published yet.</Callout>
152 + ) : (
153 + <div className="mt-4 divide-y divide-rule border-y border-rule">
154 + {metrics.map((m) => (
155 + <article key={m.key} id={`metric-${m.key}`} className="scroll-mt-[calc(var(--header-h)+1rem)] grid gap-3 py-5 md:grid-cols-[220px_minmax(0,1fr)]">
156 + <div className="min-w-0">
157 + <h3 className="text-base font-semibold text-ink">{m.name}</h3>
158 + <p className="mono mt-1 text-xs text-ink-3">
159 + {m.key} · v{m.version}
160 + </p>
161 + <p className="mt-1 text-xs text-ink-3" title={fmtDateTime(m.updated_at)}>
162 + updated {fmtDateTime(m.updated_at)}
163 + </p>
164 + </div>
165 + <div className="min-w-0">
166 + <p className="text-sm leading-relaxed text-ink-2">{m.methodology}</p>
167 + <p className="eyebrow mt-3">Inputs</p>
168 + <ul className="mt-1 flex flex-wrap gap-1">
169 + {m.inputs.map((i) => (
170 + <li key={i}>
171 + <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-[11px] text-ink">{i}</code>
172 + </li>
173 + ))}
174 + </ul>
175 + </div>
176 + </article>
177 + ))}
178 + </div>
179 + )}
180 + </DocSection>
181 + );
182 +}
added apps/web/src/components/meta/prose.tsx +101 −0
@@ -0,0 +1,101 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +
5 +/** Long-form typography primitives for the meta pages (methodology, about, privacy, terms, developers). Server-safe. */
6 +
7 +export function Prose({ children, className }: { children: ReactNode; className?: string }) {
8 + return <div className={cn('max-w-3xl text-[15px] leading-relaxed text-ink-2 [&_p+p]:mt-4 [&_strong]:font-semibold [&_strong]:text-ink [&_ul]:mt-3 [&_ul]:space-y-1.5 [&_ul]:pl-5 [&_ul]:list-disc [&_ol]:mt-3 [&_ol]:space-y-1.5 [&_ol]:pl-5 [&_ol]:list-decimal [&_code]:mono [&_code]:rounded [&_code]:bg-plane-2 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[13px] [&_code]:text-ink', className)}>{children}</div>;
9 +}
10 +
11 +/** Anchored section with a hairline on top; `id` is used by the TOC. */
12 +export function DocSection({ id, eyebrow, title, children, className }: { id: string; eyebrow?: string; title: ReactNode; children: ReactNode; className?: string }) {
13 + return (
14 + <section id={id} className={cn('scroll-mt-[calc(var(--header-h)+1rem)] border-t border-rule py-8 md:py-10', className)}>
15 + {eyebrow && <p className="eyebrow">{eyebrow}</p>}
16 + <h2 className="mt-1 text-xl font-semibold tracking-tight md:text-2xl">
17 + <a href={`#${id}`} className="hover:text-accent">
18 + {title}
19 + </a>
20 + </h2>
21 + <div className="mt-4">{children}</div>
22 + </section>
23 + );
24 +}
25 +
26 +export function SubHeading({ children, id }: { children: ReactNode; id?: string }) {
27 + return (
28 + <h3 id={id} className="mt-6 scroll-mt-[calc(var(--header-h)+1rem)] text-base font-semibold text-ink">
29 + {children}
30 + </h3>
31 + );
32 +}
33 +
34 +/** Sticky mini table of contents (desktop); a compact inline list on mobile. DOM order = visual order (TOC first on all breakpoints). */
35 +export function Toc({ items, className }: { items: { id: string; label: string }[]; className?: string }) {
36 + return (
37 + <nav aria-label="Contents" className={cn('min-w-0 max-w-full lg:sticky lg:top-[calc(var(--header-h)+1.5rem)] lg:self-start', className)}>
38 + <p className="eyebrow mb-2">Contents</p>
39 + <ol className="no-scrollbar flex max-w-full gap-1 overflow-x-auto lg:block lg:space-y-0.5 lg:overflow-visible">
40 + {items.map((it, i) => (
41 + <li key={it.id} className="shrink-0">
42 + <a href={`#${it.id}`} className="inline-flex min-h-9 items-center gap-2 rounded-md border border-rule px-2.5 text-xs text-ink-2 hover:bg-plane-2 hover:text-ink lg:min-h-8 lg:border-0 lg:px-0 lg:text-[13px]">
43 + <span className="mono text-[10px] text-ink-3">{String(i + 1).padStart(2, '0')}</span>
44 + {it.label}
45 + </a>
46 + </li>
47 + ))}
48 + </ol>
49 + </nav>
50 + );
51 +}
52 +
53 +/** Two-column doc layout: TOC (left, sticky on desktop) + content. */
54 +export function DocLayout({ toc, children }: { toc: { id: string; label: string }[]; children: ReactNode }) {
55 + return (
56 + <div className="grid gap-8 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-14">
57 + <Toc items={toc} />
58 + <div className="min-w-0">{children}</div>
59 + </div>
60 + );
61 +}
62 +
63 +/** Code block in mono on the secondary plane; horizontal scroll instead of overflow. */
64 +export function Code({ children, className, label }: { children: string; className?: string; label?: string }) {
65 + return (
66 + <figure className={cn('mt-3 min-w-0 overflow-hidden rounded-md border border-rule bg-plane-2', className)}>
67 + {label && <figcaption className="eyebrow border-b border-rule px-3 py-1.5">{label}</figcaption>}
68 + <pre className="scrollbar-thin overflow-x-auto p-3 text-[12.5px] leading-relaxed text-ink">
69 + <code className="mono">{children}</code>
70 + </pre>
71 + </figure>
72 + );
73 +}
74 +
75 +/** Definition list rendered as hairline rows (label / value). */
76 +export function KeyValues({ rows, className }: { rows: { k: ReactNode; v: ReactNode }[]; className?: string }) {
77 + return (
78 + <dl className={cn('divide-y divide-rule border-y border-rule', className)}>
79 + {rows.map((r, i) => (
80 + <div key={i} className="grid gap-1 py-2.5 sm:grid-cols-[200px_minmax(0,1fr)] sm:gap-4">
81 + <dt className="text-xs uppercase tracking-wider text-ink-3 sm:pt-0.5">{r.k}</dt>
82 + <dd className="min-w-0 break-words text-sm text-ink">{r.v}</dd>
83 + </div>
84 + ))}
85 + </dl>
86 + );
87 +}
88 +
89 +export function Callout({ children, tone = 'info', className }: { children: ReactNode; tone?: 'info' | 'warn'; className?: string }) {
90 + return (
91 + <div className={cn('mt-4 rounded-md border-l-2 px-4 py-3 text-sm leading-relaxed', tone === 'warn' ? 'border-warn bg-warn-soft text-ink' : 'border-accent bg-accent-soft text-ink', className)}>{children}</div>
92 + );
93 +}
94 +
95 +export function MethodologyLink({ anchor, children }: { anchor?: string; children?: ReactNode }) {
96 + return (
97 + <Link href={anchor ? `/methodology#${anchor}` : '/methodology'} className="link text-sm">
98 + {children ?? 'Methodology'}
99 + </Link>
100 + );
101 +}
added apps/web/src/components/meta/sitemap-data.ts +98 −0
@@ -0,0 +1,98 @@
1 +import 'server-only';
2 +import { api, safe } from '@/lib/api';
3 +import { SITE_URL, routes } from '@/lib/site';
4 +
5 +/**
6 + * Sharded sitemap data. Shard 0 = static pages + non-satellite entities; shards 1..N = on-orbit satellites,
7 + * SATELLITES_PER_SHARD per shard (N from the API total). Served by `app/sitemap.xml/route.ts` (index) and
8 + * `app/sitemap/[id]/route.ts` (shards) — Next 16's `generateSitemaps` cannot emit an index at /sitemap.xml, which robots.txt requires.
9 + * If the API is unreachable a shard degrades to static pages only; URLs are never fabricated.
10 + */
11 +export const SATELLITES_PER_SHARD = 5000;
12 +
13 +type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
14 +export interface SitemapEntry {
15 + url: string;
16 + lastModified?: Date;
17 + changeFrequency?: ChangeFreq;
18 + priority?: number;
19 +}
20 +
21 +const STATIC: { path: string; priority: number; changeFrequency: ChangeFreq }[] = [
22 + { path: routes.home(), priority: 1, changeFrequency: 'hourly' },
23 + { path: routes.explore(), priority: 0.9, changeFrequency: 'hourly' },
24 + { path: routes.satellites(), priority: 0.9, changeFrequency: 'hourly' },
25 + { path: routes.constellations(), priority: 0.8, changeFrequency: 'daily' },
26 + { path: routes.operators(), priority: 0.8, changeFrequency: 'daily' },
27 + { path: routes.countries(), priority: 0.8, changeFrequency: 'daily' },
28 + { path: routes.launches(), priority: 0.8, changeFrequency: 'daily' },
29 + { path: routes.launchSites(), priority: 0.6, changeFrequency: 'weekly' },
30 + { path: routes.debris(), priority: 0.7, changeFrequency: 'daily' },
31 + { path: routes.reentries(), priority: 0.7, changeFrequency: 'daily' },
32 + { path: routes.events(), priority: 0.7, changeFrequency: 'hourly' },
33 + { path: routes.stats(), priority: 0.7, changeFrequency: 'daily' },
34 + { path: routes.rankings(), priority: 0.6, changeFrequency: 'daily' },
35 + { path: routes.sources(), priority: 0.5, changeFrequency: 'weekly' },
36 + { path: routes.methodology(), priority: 0.5, changeFrequency: 'monthly' },
37 + { path: routes.status(), priority: 0.3, changeFrequency: 'hourly' },
38 + { path: routes.developers(), priority: 0.5, changeFrequency: 'monthly' },
39 + { path: routes.about(), priority: 0.4, changeFrequency: 'monthly' },
40 + { path: routes.privacy(), priority: 0.2, changeFrequency: 'yearly' },
41 + { path: routes.terms(), priority: 0.2, changeFrequency: 'yearly' },
42 +];
43 +
44 +const abs = (path: string) => `${SITE_URL}${path}`;
45 +const date = (v: string | null | undefined) => (v && !Number.isNaN(new Date(v).getTime()) ? new Date(v) : undefined);
46 +
47 +/** Number of satellite shards (≥ 1 even when the API is down, so /sitemap/1.xml always exists). */
48 +export async function satelliteShardCount(): Promise<number> {
49 + const first = await safe(api.sitemapSatellites(1, SATELLITES_PER_SHARD));
50 + const total = first?.data.total ?? 0;
51 + return Math.max(1, Math.ceil(total / SATELLITES_PER_SHARD));
52 +}
53 +
54 +export async function shardEntries(shard: number): Promise<SitemapEntry[]> {
55 + if (shard === 0) {
56 + const out: SitemapEntry[] = STATIC.map((s) => ({ url: abs(s.path), changeFrequency: s.changeFrequency, priority: s.priority }));
57 + const ents = await safe(api.sitemapEntities());
58 + if (ents) {
59 + const d = ents.data;
60 + out.push(...d.constellations.map((c) => ({ url: abs(routes.constellation(c.slug)), lastModified: date(c.updated_at), changeFrequency: 'daily' as const, priority: 0.8 })));
61 + out.push(...d.operators.map((o) => ({ url: abs(routes.operator(o.slug)), lastModified: date(o.updated_at), changeFrequency: 'weekly' as const, priority: 0.7 })));
62 + out.push(...d.countries.map((c) => ({ url: abs(routes.country(c.slug)), changeFrequency: 'weekly' as const, priority: 0.7 })));
63 + out.push(...d.launches.map((l) => ({ url: abs(routes.launch(l.slug)), lastModified: date(l.updated_at), changeFrequency: 'weekly' as const, priority: 0.5 })));
64 + out.push(...d.launch_sites.map((s) => ({ url: abs(routes.launchSite(s.slug)), changeFrequency: 'monthly' as const, priority: 0.5 })));
65 + }
66 + return out;
67 + }
68 + const page = await safe(api.sitemapSatellites(shard, SATELLITES_PER_SHARD));
69 + if (!page) return [];
70 + return page.data.items.map((s) => ({
71 + url: abs(routes.satellite(s.slug)),
72 + lastModified: date(s.updated_at),
73 + changeFrequency: s.status === 'ACTIVE' ? 'daily' : 'weekly',
74 + priority: s.status === 'ACTIVE' ? 0.6 : 0.4,
75 + }));
76 +}
77 +
78 +const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
79 +
80 +export function toUrlset(entries: SitemapEntry[]): string {
81 + const body = entries
82 + .map((e) => {
83 + const parts = [`<loc>${esc(e.url)}</loc>`];
84 + if (e.lastModified) parts.push(`<lastmod>${e.lastModified.toISOString()}</lastmod>`);
85 + if (e.changeFrequency) parts.push(`<changefreq>${e.changeFrequency}</changefreq>`);
86 + if (e.priority !== undefined) parts.push(`<priority>${e.priority}</priority>`);
87 + return `<url>${parts.join('')}</url>`;
88 + })
89 + .join('\n');
90 + return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
91 +}
92 +
93 +export function toIndex(shardIds: number[], lastmod: Date = new Date()): string {
94 + const body = shardIds.map((id) => `<sitemap><loc>${esc(`${SITE_URL}/sitemap/${id}.xml`)}</loc><lastmod>${lastmod.toISOString()}</lastmod></sitemap>`).join('\n');
95 + return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
96 +}
97 +
98 +export const SITEMAP_HEADERS = { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400' };
added apps/web/src/components/satellite/explorer-filters.tsx +174 −0
@@ -0,0 +1,174 @@
1 +import Link from 'next/link';
2 +import { fmtInt } from '@/lib/format';
3 +import { MISSION_LABELS, OBJECT_TYPE_LABELS, routes } from '@/lib/site';
4 +import type { Facets, FacetValue } from '@/lib/types';
5 +import { Chip } from './primitives';
6 +
7 +/** URL state of the /satellites explorer. Every filter is a plain query param so the page works without JS. */
8 +export const FILTER_KEYS = ['q', 'status', 'object_type', 'orbit_class', 'mission_type', 'country', 'operator', 'constellation', 'launch', 'launch_site', 'tag', 'on_orbit', 'has_gp', 'launched_after', 'launched_before', 'sort', 'page'] as const;
9 +export type FilterKey = (typeof FILTER_KEYS)[number];
10 +export type SatQuery = Partial<Record<FilterKey, string>>;
11 +
12 +export const STATUSES = ['ACTIVE', 'INACTIVE', 'DECAYED', 'UNKNOWN', 'LOST', 'FAILED', 'PLANNED'];
13 +export const OBJECT_TYPES = ['PAYLOAD', 'ROCKET_BODY', 'DEBRIS', 'STATION', 'UNKNOWN'];
14 +export const ORBIT_CLASSES = ['LEO', 'MEO', 'GEO', 'HEO', 'OTHER'];
15 +export const SORTS: [string, string][] = [
16 + ['launch_date', 'Newest launch'],
17 + ['-launch_date', 'Oldest launch'],
18 + ['name', 'Name A→Z'],
19 + ['-name', 'Name Z→A'],
20 + ['norad', 'NORAD ascending'],
21 + ['-norad', 'NORAD descending'],
22 + ['perigee', 'Lowest perigee'],
23 + ['-apogee', 'Highest apogee'],
24 + ['inclination', 'Inclination ascending'],
25 + ['-inclination', 'Inclination descending'],
26 + ['period', 'Shortest period'],
27 + ['decay_date', 'Most recent decay'],
28 + ['epoch', 'Freshest element set'],
29 + ['updated', 'Recently updated'],
30 +];
31 +
32 +export function parseQuery(sp: Record<string, string | string[] | undefined>): SatQuery {
33 + const q: SatQuery = {};
34 + for (const k of FILTER_KEYS) {
35 + const v = sp[k];
36 + const s = Array.isArray(v) ? v[0] : v;
37 + if (s && s.trim()) q[k] = s.trim().slice(0, 120);
38 + }
39 + return q;
40 +}
41 +
42 +export function href(q: SatQuery, patch: SatQuery = {}, dropPage = true): string {
43 + const merged: SatQuery = { ...q, ...patch };
44 + if (dropPage) delete merged.page;
45 + const p = new URLSearchParams();
46 + for (const k of FILTER_KEYS) {
47 + const v = merged[k];
48 + if (v) p.set(k, v);
49 + }
50 + const s = p.toString();
51 + return routes.satellites(s || undefined);
52 +}
53 +
54 +const cap = (s: string) => s.charAt(0) + s.slice(1).toLowerCase();
55 +
56 +/** "Active Starlink satellites in LEO" — built only from the filters actually applied. */
57 +export function titleFor(q: SatQuery, facets: Facets | null): string {
58 + const NOUN: Record<string, string> = { PAYLOAD: 'payloads', ROCKET_BODY: 'rocket bodies', DEBRIS: 'debris objects', STATION: 'space stations', UNKNOWN: 'unclassified objects', CREWED: 'crewed vehicles' };
59 + const parts: string[] = [];
60 + if (q.status) parts.push(q.status.split(',').map(cap).join(' / '));
61 + const constellation = facets?.constellation.find((f) => f.value === q.constellation)?.label ?? (q.constellation ? q.constellation.replace(/-/g, ' ') : null);
62 + if (constellation) parts.push(constellation);
63 + if (q.mission_type) parts.push((MISSION_LABELS[q.mission_type] ?? q.mission_type).toLowerCase());
64 + parts.push(q.object_type ? NOUN[q.object_type] ?? q.object_type.toLowerCase() : 'satellites');
65 + let t = parts.join(' ');
66 + if (q.orbit_class) t += ` in ${q.orbit_class}`;
67 + const country = facets?.country.find((f) => f.value === q.country || f.slug === q.country)?.label;
68 + if (country) t += ` from ${country}`;
69 + else if (q.country) t += ` from ${q.country.replace(/-/g, ' ')}`;
70 + if (q.tag) t += ` tagged “${q.tag}”`;
71 + if (q.q) t += ` matching “${q.q}”`;
72 + return t.charAt(0).toUpperCase() + t.slice(1);
73 +}
74 +
75 +function Select({ name, label, value, options, any = 'Any' }: { name: FilterKey; label: string; value?: string; options: [string, string][]; any?: string | null }) {
76 + return (
77 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
78 + {label}
79 + <select name={name} defaultValue={value ?? ''} className="h-10 min-w-0 rounded-md border border-rule bg-plane-2 px-2 text-sm text-ink">
80 + {any !== null && <option value="">{any}</option>}
81 + {options.map(([v, l]) => (
82 + <option key={v} value={v}>{l}</option>
83 + ))}
84 + </select>
85 + </label>
86 + );
87 +}
88 +
89 +export function FilterForm({ q }: { q: SatQuery }) {
90 + const hidden: FilterKey[] = ['q', 'country', 'operator', 'constellation', 'launch', 'launch_site', 'tag'];
91 + return (
92 + <form method="get" action={routes.satellites()} className="rounded-lg border border-rule bg-plane/60 p-3 md:p-4">
93 + {hidden.map((k) => q[k] && <input key={k} type="hidden" name={k} value={q[k]} />)}
94 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
95 + <Select name="status" label="Status" value={q.status} options={STATUSES.map((s) => [s, cap(s)])} />
96 + <Select name="object_type" label="Object type" value={q.object_type} options={OBJECT_TYPES.map((s) => [s, OBJECT_TYPE_LABELS[s] ?? s])} />
97 + <Select name="orbit_class" label="Orbit class" value={q.orbit_class} options={ORBIT_CLASSES.map((s) => [s, s])} />
98 + <Select name="mission_type" label="Mission (derived)" value={q.mission_type} options={Object.entries(MISSION_LABELS)} />
99 + <Select name="on_orbit" label="On orbit" value={q.on_orbit} options={[['true', 'On orbit'], ['false', 'Decayed']]} />
100 + <Select name="has_gp" label="Element set" value={q.has_gp} options={[['true', 'Has elements'], ['false', 'No elements']]} />
101 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
102 + Launched after
103 + <input type="date" name="launched_after" defaultValue={q.launched_after ?? ''} className="h-10 min-w-0 rounded-md border border-rule bg-plane-2 px-2 text-sm text-ink" />
104 + </label>
105 + <label className="flex min-w-0 flex-col gap-1 text-xs text-ink-3">
106 + Launched before
107 + <input type="date" name="launched_before" defaultValue={q.launched_before ?? ''} className="h-10 min-w-0 rounded-md border border-rule bg-plane-2 px-2 text-sm text-ink" />
108 + </label>
109 + </div>
110 + <div className="mt-3 flex flex-wrap items-end gap-3">
111 + <Select name="sort" label="Sort" value={q.sort ?? 'launch_date'} options={SORTS} any={null} />
112 + <button type="submit" className="h-10 rounded-md bg-accent px-4 text-sm font-medium text-accent-ink hover:brightness-110">Apply filters</button>
113 + {Object.keys(q).length > 0 && (
114 + <Link href={routes.satellites()} className="inline-flex h-10 items-center rounded-md border border-rule px-3 text-sm text-ink-2 hover:bg-plane-2">
115 + Reset
116 + </Link>
117 + )}
118 + </div>
119 + </form>
120 + );
121 +}
122 +
123 +function FacetRow({ label, values, param, q, labelOf }: { label: string; values: FacetValue[]; param: FilterKey; q: SatQuery; labelOf?: (v: FacetValue) => string }) {
124 + if (!values.length) return null;
125 + return (
126 + <div className="flex flex-col gap-1.5 md:flex-row md:items-start md:gap-4">
127 + <p className="eyebrow shrink-0 pt-2.5 md:w-28">{label}</p>
128 + <ul className="flex flex-wrap gap-1.5">
129 + {values.map((f) => {
130 + const key = param === 'country' && f.slug ? f.slug : f.value;
131 + const active = q[param] === key || q[param] === f.value;
132 + return (
133 + <li key={f.value}>
134 + <Chip href={href(q, { [param]: active ? undefined : key })} active={active} count={fmtInt(f.count)}>
135 + {labelOf ? labelOf(f) : f.label ?? f.value}
136 + </Chip>
137 + </li>
138 + );
139 + })}
140 + </ul>
141 + </div>
142 + );
143 +}
144 +
145 +/** Real facet counts for the current filter set (chips toggle the filter). */
146 +export function FacetChips({ facets, q }: { facets: Facets; q: SatQuery }) {
147 + return (
148 + <div className="space-y-3">
149 + <FacetRow label="Status" values={facets.status} param="status" q={q} labelOf={(f) => cap(f.value)} />
150 + <FacetRow label="Object type" values={facets.object_type} param="object_type" q={q} labelOf={(f) => OBJECT_TYPE_LABELS[f.value] ?? f.value} />
151 + <FacetRow label="Orbit class" values={facets.orbit_class} param="orbit_class" q={q} />
152 + <FacetRow label="Mission" values={facets.mission_type} param="mission_type" q={q} labelOf={(f) => MISSION_LABELS[f.value] ?? f.value} />
153 + <FacetRow label="Constellation" values={facets.constellation.slice(0, 16)} param="constellation" q={q} />
154 + <FacetRow label="Country" values={facets.country.slice(0, 16)} param="country" q={q} labelOf={(f) => f.label ?? f.value} />
155 + </div>
156 + );
157 +}
158 +
159 +/** Active filter chips with a remove action (works without JS — each is a link). */
160 +export function ActiveFilters({ q }: { q: SatQuery }) {
161 + const entries = FILTER_KEYS.filter((k) => k !== 'page' && k !== 'sort' && q[k]);
162 + if (!entries.length) return null;
163 + return (
164 + <ul className="flex flex-wrap gap-1.5">
165 + {entries.map((k) => (
166 + <li key={k}>
167 + <Link href={href(q, { [k]: undefined })} className="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-accent/40 bg-accent-soft px-2.5 py-1 text-xs text-accent hover:bg-accent/20" title={`Remove ${k} filter`}>
168 + <span className="text-accent/70">{k.replace(/_/g, ' ')}:</span> {q[k]} <span aria-hidden>×</span>
169 + </Link>
170 + </li>
171 + ))}
172 + </ul>
173 + );
174 +}
added apps/web/src/components/satellite/results-table.tsx +49 −0
@@ -0,0 +1,49 @@
1 +import Link from 'next/link';
2 +import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';
3 +import { fmtDate, fmtDeg, fmtInt } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import type { SatelliteRow } from '@/lib/types';
6 +
7 +export function ResultsTable({ rows }: { rows: SatelliteRow[] }) {
8 + return (
9 + <div className="overflow-x-auto scrollbar-thin">
10 + <table className="data-table stack">
11 + <thead>
12 + <tr>
13 + <th>Name</th>
14 + <th>NORAD</th>
15 + <th>COSPAR</th>
16 + <th>Type</th>
17 + <th>Status</th>
18 + <th>Orbit</th>
19 + <th className="num">Perigee / apogee</th>
20 + <th className="num">Incl.</th>
21 + <th>Launched</th>
22 + <th>Operator</th>
23 + <th>Country</th>
24 + </tr>
25 + </thead>
26 + <tbody>
27 + {rows.map((s) => (
28 + <tr key={s.id}>
29 + <td data-label="Name" className="primary">
30 + <Link href={routes.satellite(s.slug)} prefetch={false} className="link font-medium">{s.name}</Link>
31 + {s.constellation_name && <span className="ml-2 text-2xs text-ink-3">{s.constellation_name}</span>}
32 + </td>
33 + <td data-label="NORAD" className="mono text-xs text-ink-2">{s.norad_id ?? '—'}</td>
34 + <td data-label="COSPAR" className="mono text-xs text-ink-2">{s.cospar_id ?? '—'}</td>
35 + <td data-label="Type"><TypeBadge type={s.object_type} /></td>
36 + <td data-label="Status"><StatusBadge status={s.status} /></td>
37 + <td data-label="Orbit"><OrbitBadge orbitClass={s.orbit_class} /></td>
38 + <td data-label="Perigee / apogee" className="num mono text-xs">{s.perigee_km !== null ? `${fmtInt(s.perigee_km)} / ${fmtInt(s.apogee_km)} km` : '—'}</td>
39 + <td data-label="Inclination" className="num mono text-xs">{fmtDeg(s.inclination_deg)}</td>
40 + <td data-label="Launched" className="mono text-xs">{fmtDate(s.launch_date)}{s.decay_date && <span className="block text-ink-3">↓ {fmtDate(s.decay_date)}</span>}</td>
41 + <td data-label="Operator" className="text-xs">{s.operator_slug ? <Link className="link" href={routes.operator(s.operator_slug)}>{s.operator_name}</Link> : s.owner_name ?? s.owner_code ?? '—'}</td>
42 + <td data-label="Country" className="text-xs">{s.country_slug ? <Link className="link" href={routes.country(s.country_slug)}>{s.country_name}</Link> : '—'}</td>
43 + </tr>
44 + ))}
45 + </tbody>
46 + </table>
47 + </div>
48 + );
49 +}
added apps/web/src/components/satellite/search-box.tsx +60 −0
@@ -0,0 +1,60 @@
1 +'use client';
2 +import { Search, X } from 'lucide-react';
3 +import { useRouter, useSearchParams } from 'next/navigation';
4 +import { useState } from 'react';
5 +import { routes } from '@/lib/site';
6 +
7 +/** Compact search that navigates to `?q=` while keeping the other filters. Degrades to a plain GET form without JS. */
8 +export function SearchBox({ initial = '' }: { initial?: string }) {
9 + const router = useRouter();
10 + const sp = useSearchParams();
11 + const [value, setValue] = useState(initial);
12 +
13 + const go = (q: string) => {
14 + const p = new URLSearchParams(sp.toString());
15 + p.delete('page');
16 + if (q.trim()) p.set('q', q.trim());
17 + else p.delete('q');
18 + const s = p.toString();
19 + router.push(routes.satellites(s || undefined));
20 + };
21 +
22 + return (
23 + <form
24 + method="get"
25 + action={routes.satellites()}
26 + role="search"
27 + className="relative flex w-full max-w-md items-center"
28 + onSubmit={(e) => {
29 + e.preventDefault();
30 + go(value);
31 + }}
32 + >
33 + {Array.from(sp.entries()).filter(([k]) => k !== 'q' && k !== 'page').map(([k, v]) => <input key={k} type="hidden" name={k} value={v} />)}
34 + <Search className="pointer-events-none absolute left-3 size-4 text-ink-3" aria-hidden />
35 + <input
36 + type="search"
37 + name="q"
38 + value={value}
39 + onChange={(e) => setValue(e.target.value)}
40 + placeholder="Name, NORAD or COSPAR id…"
41 + aria-label="Search satellites by name, NORAD or COSPAR id"
42 + className="h-11 w-full rounded-md border border-rule bg-plane-2 pl-9 pr-10 text-sm text-ink placeholder:text-ink-3 focus:border-accent/60"
43 + autoComplete="off"
44 + />
45 + {value && (
46 + <button
47 + type="button"
48 + onClick={() => {
49 + setValue('');
50 + go('');
51 + }}
52 + className="absolute right-1 inline-flex size-9 items-center justify-center rounded text-ink-3 hover:text-ink"
53 + aria-label="Clear search"
54 + >
55 + <X className="size-4" aria-hidden />
56 + </button>
57 + )}
58 + </form>
59 + );
60 +}
added apps/web/src/components/satellite/sections.tsx +325 −0
@@ -0,0 +1,325 @@
1 +import Link from 'next/link';
2 +import { MissionLabel, OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';
3 +import { Unavailable } from '@/components/ui/unavailable';
4 +import { fmt1, fmtAgo, fmtDate, fmtDateTime, fmtDeg, fmtInt, fmtKm, fmtMinutes, titleCase } from '@/lib/format';
5 +import { EVENT_TYPE_LABELS, OBJECT_TYPE_LABELS, routes } from '@/lib/site';
6 +import type { SatelliteDetail, SatelliteHistory } from '@/lib/types';
7 +import { AltitudeChart } from './altitude-chart';
8 +import { OPS_STATUS } from './hero';
9 +import { Block, Chip, DL, Derived, Empty, Head, Note, Row } from './primitives';
10 +
11 +const sci = (v: number | null) => (v === null ? '—' : v === 0 ? '0' : v.toExponential(4));
12 +
13 +export 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 + )}
46 +
47 + <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 +}
63 +
64 +export 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 +}
109 +
110 +export 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 +}
122 +
123 +export 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 +}
159 +
160 +export 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 +}
186 +
187 +export 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 +}
195 +
196 +export 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 + )}
221 +
222 + <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 + )}
244 +
245 + {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 +}
262 +
263 +export 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 +}
286 +
287 +export 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 +}
309 +
310 +export 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 +}
added apps/web/src/components/satellite/skeleton.tsx +32 −0
@@ -0,0 +1,32 @@
1 +/** Pulse skeletons used as Suspense fallbacks (the route itself is not wrapped in loading.tsx so 308/404 status codes stay real). */
2 +export function Bone({ className }: { className: string }) {
3 + return <div className={`animate-pulse rounded bg-plane-2 ${className}`} aria-hidden />;
4 +}
5 +
6 +export function SectionSkeleton({ rows = 8, chart = false, title }: { rows?: number; chart?: boolean; title?: string }) {
7 + return (
8 + <div className="py-6 md:py-8" role="status" aria-label={title ?? 'Loading section'}>
9 + <div className="mb-4 border-b border-rule pb-3">
10 + <Bone className="h-3 w-16" />
11 + <Bone className="mt-2 h-6 w-48" />
12 + </div>
13 + {chart && <Bone className="mb-6 h-44 w-full" />}
14 + <div className="space-y-3">
15 + {Array.from({ length: rows }).map((_, i) => (
16 + <Bone key={i} className="h-4 w-full" />
17 + ))}
18 + </div>
19 + </div>
20 + );
21 +}
22 +
23 +export function TableSkeleton({ rows = 12 }: { rows?: number }) {
24 + return (
25 + <div className="space-y-2" role="status" aria-label="Loading table">
26 + <Bone className="h-8 w-full" />
27 + {Array.from({ length: rows }).map((_, i) => (
28 + <Bone key={i} className="h-10 w-full" />
29 + ))}
30 + </div>
31 + );
32 +}
added apps/web/src/components/search/results.tsx +72 −0
@@ -0,0 +1,72 @@
1 +import { ArrowRight, Building2, Globe2, MapPin, Orbit, Rocket, Satellite } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { fmtInt } from '@/lib/format';
4 +import type { SearchPayload, SearchResult } from '@/lib/types';
5 +
6 +type EntityType = SearchResult['entity_type'];
7 +
8 +const GROUPS: { type: EntityType; label: string; Icon: typeof Satellite }[] = [
9 + { type: 'satellite', label: 'Satellites', Icon: Satellite },
10 + { type: 'constellation', label: 'Constellations', Icon: Orbit },
11 + { type: 'operator', label: 'Operators', Icon: Building2 },
12 + { type: 'country', label: 'Countries', Icon: Globe2 },
13 + { type: 'launch', label: 'Launches', Icon: Rocket },
14 + { type: 'launch_site', label: 'Launch sites', Icon: MapPin },
15 +];
16 +
17 +export function Shortcuts({ items }: { items: SearchPayload['shortcuts'] }) {
18 + if (!items.length) return null;
19 + return (
20 + <div className="mb-6">
21 + <p className="eyebrow mb-2">Filters matching your query</p>
22 + <ul className="flex flex-wrap gap-2">
23 + {items.map((s) => (
24 + <li key={s.href}>
25 + <Link href={s.href} className="inline-flex min-h-[40px] items-center gap-1.5 rounded-full border border-rule bg-plane-2 px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
26 + <ArrowRight className="size-3.5 text-accent" aria-hidden /> {s.label}
27 + </Link>
28 + </li>
29 + ))}
30 + </ul>
31 + </div>
32 + );
33 +}
34 +
35 +/** Results grouped by entity type in a fixed order; every row links to `result.href` from the API. */
36 +export function GroupedResults({ results }: { results: SearchResult[] }) {
37 + const byType = new Map<EntityType, SearchResult[]>();
38 + for (const r of results) byType.set(r.entity_type, [...(byType.get(r.entity_type) ?? []), r]);
39 + const groups = GROUPS.filter((g) => byType.has(g.type));
40 + return (
41 + <div className="space-y-10">
42 + {groups.map(({ type, label, Icon }) => {
43 + const rows = byType.get(type) ?? [];
44 + return (
45 + <section key={type} aria-labelledby={`grp-${type}`}>
46 + <div className="mb-2 flex items-baseline justify-between gap-3">
47 + <h2 id={`grp-${type}`} className="flex items-center gap-2 text-base font-semibold text-ink">
48 + <Icon className="size-4 text-ink-3" aria-hidden /> {label}
49 + </h2>
50 + <span className="tnum text-xs text-ink-3">{fmtInt(rows.length)}</span>
51 + </div>
52 + <ul className="divide-y divide-rule border-t border-rule">
53 + {rows.map((r) => (
54 + <li key={`${r.entity_type}-${r.entity_id}`}>
55 + <Link href={r.href} className="group flex min-h-[52px] items-center gap-3 py-2.5">
56 + <span className="min-w-0 flex-1">
57 + <span className="block truncate text-[15px] text-ink group-hover:text-accent">{r.title}</span>
58 + {r.subtitle && <span className="block truncate text-xs text-ink-3">{r.subtitle}</span>}
59 + </span>
60 + <ArrowRight className="size-4 shrink-0 text-ink-3 group-hover:text-accent" aria-hidden />
61 + </Link>
62 + </li>
63 + ))}
64 + </ul>
65 + </section>
66 + );
67 + })}
68 + </div>
69 + );
70 +}
71 +
72 +export const EXAMPLE_QUERIES = ['ISS', 'Starlink', '25544', '1998-067A', 'SpaceX', 'Canada', 'GPS', 'Falcon 9'];
added apps/web/src/components/search/search-form.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import { Search } from 'lucide-react';
2 +import { cn } from '@/lib/cn';
3 +
4 +/** Plain GET form — works without JavaScript; the ⌘K dialog is the enhanced path. */
5 +export function SearchForm({ q, className, autoFocus = false }: { q: string; className?: string; autoFocus?: boolean }) {
6 + return (
7 + <form action="/search" method="get" role="search" className={cn('flex gap-2', className)}>
8 + <label className="flex h-12 min-w-0 flex-1 items-center gap-2 rounded-md border border-rule bg-plane-2/70 px-3 focus-within:border-rule-strong">
9 + <Search className="size-4 shrink-0 text-ink-3" aria-hidden />
10 + <span className="sr-only">Search query</span>
11 + <input
12 + type="search"
13 + name="q"
14 + defaultValue={q}
15 + placeholder="Satellite, NORAD, COSPAR, operator, constellation, country…"
16 + className="min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"
17 + autoComplete="off"
18 + spellCheck={false}
19 + autoFocus={autoFocus}
20 + maxLength={120}
21 + />
22 + </label>
23 + <button type="submit" className="h-12 shrink-0 rounded-md bg-accent px-4 text-sm font-semibold text-accent-ink hover:brightness-110">
24 + Search
25 + </button>
26 + </form>
27 + );
28 +}
added apps/web/src/components/stats/rankings-config.tsx +206 −0
@@ -0,0 +1,206 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { fmt1, fmtDate, fmtInt, fmtPct, num } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import { Derived } from './shared';
6 +
7 +export type Row = Record<string, unknown>;
8 +export const str = (r: Row, k: string): string | null => (typeof r[k] === 'string' ? (r[k] as string) : r[k] == null ? null : String(r[k]));
9 +export const n = (r: Row, k: string): number => num(r[k] as number | string | null) ?? 0;
10 +
11 +export interface Column {
12 + key: string;
13 + label: string;
14 + num?: boolean;
15 + derived?: boolean;
16 + render: (r: Row) => ReactNode;
17 +}
18 +
19 +export interface MetricDef {
20 + key: string;
21 + label: string;
22 + title: string;
23 + description: string;
24 + /** Label for the HBars chart / primary value. */
25 + primaryLabel: string;
26 + primary: (r: Row) => number;
27 + primaryFormat?: (v: number) => string;
28 + name: (r: Row) => string;
29 + href: (r: Row) => string | null;
30 + columns: Column[];
31 + note?: ReactNode;
32 + warn?: string;
33 +}
34 +
35 +function NameCell({ href, name, sub, mono = false }: { href: string | null; name: string; sub?: ReactNode; mono?: boolean }) {
36 + return (
37 + <div className="min-w-0">
38 + {href ? (
39 + <Link href={href} className={`link font-medium ${mono ? 'mono' : ''}`}>{name}</Link>
40 + ) : (
41 + <span className={`font-medium ${mono ? 'mono' : ''}`}>{name}</span>
42 + )}
43 + {sub && <p className="mt-0.5 truncate text-xs text-ink-3">{sub}</p>}
44 + </div>
45 + );
46 +}
47 +
48 +const shellHref = (shell: number) => routes.satellites(`orbit_class=LEO&min_perigee=${shell}&max_perigee=${shell + 50}&on_orbit=true`);
49 +
50 +export const METRICS: MetricDef[] = [
51 + {
52 + key: 'constellations',
53 + label: 'Constellations',
54 + title: 'Largest constellations by active satellites',
55 + description: 'Constellations ranked by active satellites, with fleet size, launches in the last 365 days and the derived activity score.',
56 + primaryLabel: 'Active satellites',
57 + primary: (r) => n(r, 'active'),
58 + name: (r) => str(r, 'name') ?? '—',
59 + href: (r) => (str(r, 'slug') ? routes.constellation(str(r, 'slug') as string) : null),
60 + columns: [
61 + { key: 'name', label: 'Constellation', render: (r) => <NameCell href={str(r, 'slug') ? routes.constellation(str(r, 'slug') as string) : null} name={str(r, 'name') ?? '—'} sub={str(r, 'operator_slug') ? <Link href={routes.operator(str(r, 'operator_slug') as string)} className="hover:text-accent">{str(r, 'operator_name')}</Link> : str(r, 'operator_name')} /> },
62 + { key: 'active', label: 'Active', num: true, render: (r) => fmtInt(n(r, 'active')) },
63 + { key: 'total', label: 'Total', num: true, render: (r) => fmtInt(n(r, 'total')) },
64 + { key: 'launched_last_365d', label: 'Launched 365 d', num: true, render: (r) => fmtInt(n(r, 'launched_last_365d')) },
65 + { key: 'activity_score', label: 'Activity score', num: true, derived: true, render: (r) => <span className="mono">{fmt1(r['activity_score'] as number | string | null)}</span> },
66 + ],
67 + note: (
68 + <>
69 + Constellation membership and the activity score are <Derived className="mx-1" /> metrics (0–10, weighted recent launch cadence and fleet health).
70 + </>
71 + ),
72 + },
73 + {
74 + key: 'operators',
75 + label: 'Operators',
76 + title: 'Largest operators by active payloads',
77 + description: 'Operators ranked by active payloads on orbit, with total fleet, payloads launched in the last 365 days, constellations and launches.',
78 + primaryLabel: 'Active payloads',
79 + primary: (r) => n(r, 'active_payloads'),
80 + name: (r) => str(r, 'name') ?? '—',
81 + href: (r) => (str(r, 'slug') ? routes.operator(str(r, 'slug') as string) : null),
82 + columns: [
83 + { key: 'name', label: 'Operator', render: (r) => <NameCell href={str(r, 'slug') ? routes.operator(str(r, 'slug') as string) : null} name={str(r, 'name') ?? '—'} sub={str(r, 'country_name') ?? str(r, 'country_code') ?? undefined} /> },
84 + { key: 'active_payloads', label: 'Active', num: true, render: (r) => fmtInt(n(r, 'active_payloads')) },
85 + { key: 'total_payloads', label: 'Total payloads', num: true, render: (r) => fmtInt(n(r, 'total_payloads')) },
86 + { key: 'payloads_last_365d', label: 'Launched 365 d', num: true, render: (r) => fmtInt(n(r, 'payloads_last_365d')) },
87 + { key: 'constellations', label: 'Constellations', num: true, render: (r) => fmtInt(n(r, 'constellations')) },
88 + { key: 'launches', label: 'Launches', num: true, render: (r) => fmtInt(n(r, 'launches')) },
89 + ],
90 + },
91 + {
92 + key: 'countries',
93 + label: 'Countries',
94 + title: 'Countries by active payloads',
95 + description: 'Countries ranked by active payloads, with objects on orbit, operators, launches and payloads launched in the last 365 days (SATCAT owner codes mapped to ISO 3166).',
96 + primaryLabel: 'Active payloads',
97 + primary: (r) => n(r, 'active_payloads'),
98 + name: (r) => str(r, 'name') ?? '—',
99 + href: (r) => (str(r, 'slug') ? routes.country(str(r, 'slug') as string) : null),
100 + columns: [
101 + { key: 'name', label: 'Country', render: (r) => <NameCell href={str(r, 'slug') ? routes.country(str(r, 'slug') as string) : null} name={str(r, 'name') ?? '—'} sub={<span className="mono">{str(r, 'code')}</span>} /> },
102 + { key: 'active_payloads', label: 'Active payloads', num: true, render: (r) => fmtInt(n(r, 'active_payloads')) },
103 + { key: 'on_orbit_payloads', label: 'Payloads on orbit', num: true, render: (r) => fmtInt(n(r, 'on_orbit_payloads')) },
104 + { key: 'objects_on_orbit', label: 'Objects on orbit', num: true, render: (r) => fmtInt(n(r, 'objects_on_orbit')) },
105 + { key: 'operators', label: 'Operators', num: true, render: (r) => fmtInt(n(r, 'operators')) },
106 + { key: 'launches', label: 'Launches', num: true, render: (r) => fmtInt(n(r, 'launches')) },
107 + { key: 'payloads_last_365d', label: 'Launched 365 d', num: true, render: (r) => fmtInt(n(r, 'payloads_last_365d')) },
108 + ],
109 + },
110 + {
111 + key: 'countries-debris',
112 + label: 'Debris by country',
113 + title: 'Countries by debris on orbit',
114 + description: 'Countries ranked by catalogued debris fragments currently on orbit, with rocket bodies, objects on orbit and active payloads. Object counts only.',
115 + primaryLabel: 'Debris on orbit',
116 + primary: (r) => n(r, 'debris_on_orbit'),
117 + name: (r) => str(r, 'name') ?? '—',
118 + href: (r) => (str(r, 'slug') ? routes.country(str(r, 'slug') as string) : null),
119 + columns: [
120 + { key: 'name', label: 'Country', render: (r) => <NameCell href={str(r, 'slug') ? routes.country(str(r, 'slug') as string) : null} name={str(r, 'name') ?? '—'} sub={<span className="mono">{str(r, 'code')}</span>} /> },
121 + { key: 'debris_on_orbit', label: 'Debris on orbit', num: true, render: (r) => fmtInt(n(r, 'debris_on_orbit')) },
122 + { key: 'rocket_bodies_on_orbit', label: 'Rocket bodies', num: true, render: (r) => fmtInt(n(r, 'rocket_bodies_on_orbit')) },
123 + { key: 'objects_on_orbit', label: 'Objects on orbit', num: true, render: (r) => fmtInt(n(r, 'objects_on_orbit')) },
124 + { key: 'active_payloads', label: 'Active payloads', num: true, render: (r) => fmtInt(n(r, 'active_payloads')) },
125 + ],
126 + warn: 'Counts of catalogued objects attributed to the owner country. Not a collision-risk or responsibility metric.',
127 + },
128 + {
129 + key: 'launches',
130 + label: 'Launch sites',
131 + title: 'Busiest launch sites',
132 + description: 'Launch sites ranked by orbital launches with at least one catalogued object, with launches in the last 365 days, payloads and last launch date.',
133 + primaryLabel: 'Launches',
134 + primary: (r) => n(r, 'launches'),
135 + name: (r) => str(r, 'name') ?? '—',
136 + href: (r) => (str(r, 'slug') ? routes.launchSite(str(r, 'slug') as string) : null),
137 + columns: [
138 + { key: 'name', label: 'Launch site', render: (r) => <NameCell href={str(r, 'slug') ? routes.launchSite(str(r, 'slug') as string) : null} name={str(r, 'name') ?? '—'} sub={<><span className="mono">{str(r, 'code')}</span> · {str(r, 'country_name') ?? str(r, 'country_code') ?? '—'}</>} /> },
139 + { key: 'launches', label: 'Launches', num: true, render: (r) => fmtInt(n(r, 'launches')) },
140 + { key: 'launches_last_365d', label: 'Last 365 d', num: true, render: (r) => fmtInt(n(r, 'launches_last_365d')) },
141 + { key: 'payloads', label: 'Payloads', num: true, render: (r) => fmtInt(n(r, 'payloads')) },
142 + { key: 'last_launch', label: 'Last launch', render: (r) => <span className="mono text-xs">{fmtDate(str(r, 'last_launch'))}</span> },
143 + ],
144 + },
145 + {
146 + key: 'fastest-growing',
147 + label: 'Fastest growing',
148 + title: 'Fastest-growing constellations',
149 + description: 'Constellations ranked by growth: satellites launched in the last 365 days relative to the fleet size a year ago (minimum 5 launched).',
150 + primaryLabel: 'Growth (%)',
151 + primary: (r) => n(r, 'growth_pct'),
152 + primaryFormat: (v) => fmtPct(v, 0),
153 + name: (r) => str(r, 'name') ?? '—',
154 + href: (r) => (str(r, 'slug') ? routes.constellation(str(r, 'slug') as string) : null),
155 + columns: [
156 + { key: 'name', label: 'Constellation', render: (r) => <NameCell href={str(r, 'slug') ? routes.constellation(str(r, 'slug') as string) : null} name={str(r, 'name') ?? '—'} sub={str(r, 'operator_name') ?? undefined} /> },
157 + { key: 'growth_pct', label: 'Growth', num: true, derived: true, render: (r) => <span className="mono text-active">+{fmtPct(n(r, 'growth_pct'), 0)}</span> },
158 + { key: 'launched_last_365d', label: 'Launched 365 d', num: true, render: (r) => fmtInt(n(r, 'launched_last_365d')) },
159 + { key: 'active', label: 'Active', num: true, render: (r) => fmtInt(n(r, 'active')) },
160 + { key: 'total', label: 'Total', num: true, render: (r) => fmtInt(n(r, 'total')) },
161 + { key: 'first_launch', label: 'First launch', render: (r) => <span className="mono text-xs">{fmtDate(str(r, 'first_launch'))}</span> },
162 + ],
163 + note: (
164 + <>
165 + Growth = satellites launched in the last 365 d ÷ fleet a year ago (total − launched last 365 d), minimum 5 launched. <Derived className="mx-1" /> New constellations with no fleet a year ago show very large percentages.
166 + </>
167 + ),
168 + },
169 + {
170 + key: 'congested-shells',
171 + label: 'LEO shells',
172 + title: 'Most populated 50 km LEO shells',
173 + description: 'LEO altitude shells (50 km, by perigee) ranked by number of tracked objects on orbit, with active payloads, debris and constellations present. Object counts only — not a collision-risk metric.',
174 + primaryLabel: 'Objects on orbit',
175 + primary: (r) => n(r, 'objects'),
176 + name: (r) => `${fmtInt(n(r, 'shell_km'))}–${fmtInt(n(r, 'shell_km') + 50)} km`,
177 + href: (r) => shellHref(n(r, 'shell_km')),
178 + columns: [
179 + { key: 'shell_km', label: 'Shell (perigee)', render: (r) => <NameCell mono href={shellHref(n(r, 'shell_km'))} name={`${fmtInt(n(r, 'shell_km'))}–${fmtInt(n(r, 'shell_km') + 50)} km`} sub="Browse objects in this shell" /> },
180 + { key: 'objects', label: 'Objects', num: true, render: (r) => fmtInt(n(r, 'objects')) },
181 + { key: 'active_payloads', label: 'Active payloads', num: true, render: (r) => fmtInt(n(r, 'active_payloads')) },
182 + { key: 'debris', label: 'Debris', num: true, render: (r) => fmtInt(n(r, 'debris')) },
183 + { key: 'constellations', label: 'Constellations', num: true, render: (r) => fmtInt(n(r, 'constellations')) },
184 + ],
185 + warn: 'SatelliteIndex orbital density (object counts per 50 km perigee shell), NOT a collision-risk metric. No conjunction or collision probability is computed or implied.',
186 + },
187 + {
188 + key: 'launch-years',
189 + label: 'Launch years',
190 + title: 'Launch years by orbital launches',
191 + description: 'Calendar years ranked by orbital launches with catalogued objects, with the number of payloads catalogued for that year.',
192 + primaryLabel: 'Launches',
193 + primary: (r) => n(r, 'launches'),
194 + name: (r) => String(n(r, 'year')),
195 + href: (r) => routes.launches(`year=${n(r, 'year')}`),
196 + columns: [
197 + { key: 'year', label: 'Year', render: (r) => <NameCell mono href={routes.launches(`year=${n(r, 'year')}`)} name={String(n(r, 'year'))} /> },
198 + { key: 'launches', label: 'Launches', num: true, render: (r) => fmtInt(n(r, 'launches')) },
199 + { key: 'payloads', label: 'Payloads', num: true, render: (r) => fmtInt(n(r, 'payloads')) },
200 + { key: 'ratio', label: 'Payloads / launch', num: true, derived: true, render: (r) => <span className="mono">{n(r, 'launches') ? fmt1(n(r, 'payloads') / n(r, 'launches')) : '—'}</span> },
201 + ],
202 + },
203 +];
204 +
205 +export const DEFAULT_METRIC = 'constellations';
206 +export const findMetric = (key: string | undefined): MetricDef => METRICS.find((m) => m.key === key) ?? (METRICS[0] as MetricDef);
modified apps/web/src/components/stats/shared.tsx +7 −2
@@ -97,8 +97,13 @@ export function Chips({ items, className, ariaLabel }: { items: { href: string;
97 97 }
98 98
99 99 /** Rank number cell. */
100 −export function Rank({ n }: { n: number }) {
101 − return <span className="mono text-xs text-ink-3">{String(n).padStart(2, '0')}</span>;
100 +export function Rank({ n, className }: { n: number; className?: string }) {
101 + return <span className={cn('mono text-xs text-ink-3', className)}>{String(n).padStart(2, '0')}</span>;
102 +}
103 +
104 +/** Inline rank prefix inside the primary cell of ranked tables (works in stacked mode: `.data-table.stack td` forces display:block, so a hidden rank column would reappear on mobile). */
105 +export function InlineRank({ n }: { n: number }) {
106 + return <Rank n={n} className="mr-2 inline-block w-6 shrink-0 pt-0.5" />;
102 107 }
103 108
104 109 type Connector = StatsSnapshot['connectors'][number];
added apps/web/src/components/stats/stats-density.tsx +89 −0
@@ -0,0 +1,89 @@
1 +import Link from 'next/link';
2 +import { HBars, Histogram } from '@/components/charts/charts';
3 +import { Unavailable } from '@/components/ui/unavailable';
4 +import { fmtInt, num } from '@/lib/format';
5 +import { ORBIT_CLASS_COLORS, routes } from '@/lib/site';
6 +import type { BucketStat, DensityPayload } from '@/lib/types';
7 +import { GroupedHistogram } from './local-charts';
8 +import { ChartBlock, Note, TwoCol } from './shared';
9 +
10 +const BUCKET_ORDER = ['0-200', '200-300', '300-400', '400-500', '500-600', '600-700', '700-800', '800-900', '900-1000', '1000-2000', 'MEO', 'GEO', 'HEO', 'OTHER', 'UNKNOWN'];
11 +
12 +export function sortBuckets<T extends { bucket: string }>(buckets: T[]): T[] {
13 + const idx = (b: string) => {
14 + const i = BUCKET_ORDER.indexOf(b);
15 + return i === -1 ? BUCKET_ORDER.length : i;
16 + };
17 + return [...buckets].sort((a, b) => idx(a.bucket) - idx(b.bucket));
18 +}
19 +
20 +function bucketLabel(b: string): string {
21 + if (/^\d+-\d+$/.test(b)) return `LEO ${b} km`;
22 + return b === 'UNKNOWN' ? 'Unclassified' : b;
23 +}
24 +function bucketColor(b: string): string {
25 + if (/^\d+-\d+$/.test(b)) return ORBIT_CLASS_COLORS.LEO ?? 'var(--leo)';
26 + return ORBIT_CLASS_COLORS[b] ?? 'var(--other)';
27 +}
28 +
29 +/** Orbital density section (informational object counts). `density` null → Unavailable. */
30 +export function StatsDensity({ density, fallbackBuckets }: { density: DensityPayload | null; fallbackBuckets?: BucketStat[] }) {
31 + const buckets = sortBuckets(density?.buckets ?? fallbackBuckets ?? []);
32 + const bucketBars = buckets.map((b) => ({ label: bucketLabel(b.bucket), value: num(b.objects) ?? 0, color: bucketColor(b.bucket) }));
33 +
34 + const leo = density ? [...density.leo_profile_25km].map((r) => ({ bin: num(r.alt_km) ?? 0, values: [num(r.active_payloads) ?? 0, num(r.debris) ?? 0] })).sort((a, b) => a.bin - b.bin) : [];
35 + const incl = density ? [...density.inclination_profile_5deg].map((r) => ({ bin: num(r.incl_deg) ?? 0, value: num(r.objects) ?? 0 })).sort((a, b) => a.bin - b.bin) : [];
36 + const inclActive = density ? [...density.inclination_profile_5deg].map((r) => ({ bin: num(r.incl_deg) ?? 0, values: [num(r.active_payloads) ?? 0, (num(r.objects) ?? 0) - (num(r.active_payloads) ?? 0)] })).sort((a, b) => a.bin - b.bin) : [];
37 +
38 + return (
39 + <div className="space-y-12">
40 + <Note tone="warn">
41 + {density?.methodology ?? 'Objects on orbit by perigee altitude. Informational density, not a collision-risk metric.'} SatelliteIndex publishes object counts only — never collision probabilities.{' '}
42 + <Link href={routes.methodology()} className="text-accent hover:underline">Methodology</Link>
43 + </Note>
44 +
45 + <TwoCol>
46 + <ChartBlock title="Objects on orbit by altitude band" hint="Perigee altitude" derived>
47 + {bucketBars.length ? <HBars data={bucketBars} /> : <Unavailable what="Altitude bands" />}
48 + <table className="data-table stack mt-5 text-sm">
49 + <thead>
50 + <tr>
51 + <th>Band</th>
52 + <th className="num">Objects</th>
53 + <th className="num">Active payloads</th>
54 + <th className="num">Debris</th>
55 + <th className="num">Rocket bodies</th>
56 + </tr>
57 + </thead>
58 + <tbody>
59 + {buckets.map((b) => (
60 + <tr key={b.bucket}>
61 + <td className="primary" data-label="Band">{bucketLabel(b.bucket)}</td>
62 + <td className="num tnum" data-label="Objects">{fmtInt(b.objects)}</td>
63 + <td className="num tnum" data-label="Active payloads">{fmtInt(b.active_payloads)}</td>
64 + <td className="num tnum" data-label="Debris">{fmtInt(b.debris)}</td>
65 + <td className="num tnum" data-label="Rocket bodies">{fmtInt(b.rocket_bodies)}</td>
66 + </tr>
67 + ))}
68 + </tbody>
69 + </table>
70 + </ChartBlock>
71 +
72 + <div className="space-y-10">
73 + <ChartBlock title="LEO altitude profile" hint="25 km bins · perigee" derived>
74 + {leo.length ? <GroupedHistogram data={leo} series={[{ label: 'Active payloads', color: 'var(--series-1)' }, { label: 'Debris', color: 'var(--series-4)' }]} title="LEO altitude profile: active payloads vs debris per 25 km" unit=" km" height={180} /> : <Unavailable what="LEO profile" />}
75 + </ChartBlock>
76 + <ChartBlock title="Inclination profile" hint="5° bins · all objects on orbit" derived>
77 + {incl.length ? <Histogram data={incl} title="Objects on orbit by inclination (5° bins)" unit="°" color="var(--series-2)" height={150} xTicks={8} /> : <Unavailable what="Inclination profile" />}
78 + {inclActive.length > 0 && <GroupedHistogram data={inclActive} series={[{ label: 'Active payloads', color: 'var(--series-3)' }, { label: 'Other objects', color: 'var(--inactive)' }]} title="Inclination profile: active payloads vs other objects" unit="°" height={150} className="mt-4" />}
79 + </ChartBlock>
80 + </div>
81 + </TwoCol>
82 +
83 + <Note>
84 + Debris growth by launch year, fragmentation sources and the largest tracked objects are on <Link href={routes.debris()} className="text-accent hover:underline">/debris</Link>. The most populated 50 km shells are listed under{' '}
85 + <Link href={routes.rankings('congested-shells')} className="text-accent hover:underline">Rankings → LEO shells</Link>.
86 + </Note>
87 + </div>
88 + );
89 +}
added apps/web/src/lib/admin-api.ts +86 −0
@@ -0,0 +1,86 @@
1 +import 'server-only';
2 +import type { AdminOverview, ConnectorRunsPayload, CostsPayload, DataQualityPayload, RawRecord, RawRecordDetail, ReviewDecision, ReviewItem, AdminPaginated } from '@/components/admin/types';
3 +import type { Envelope } from './types';
4 +import { API_URL } from './api';
5 +
6 +/**
7 + * Server-only client for the FastAPI admin endpoints. Adds `x-si-admin-token` from the server environment (the browser never
8 + * sees it) and never caches. Used by admin server components and by the Next route handlers under `app/api/admin/*`.
9 + */
10 +const BASE = `${API_URL}/api/v1/admin`;
11 +
12 +export class AdminApiError extends Error {
13 + readonly status: number;
14 + readonly path: string;
15 + constructor(status: number, path: string, message: string) {
16 + super(message);
17 + this.name = 'AdminApiError';
18 + this.status = status;
19 + this.path = path;
20 + }
21 + get notFound(): boolean {
22 + return this.status === 404;
23 + }
24 +}
25 +
26 +type Query = Record<string, string | number | boolean | null | undefined>;
27 +
28 +function qs(query?: Query): string {
29 + if (!query) return '';
30 + const p = new URLSearchParams();
31 + for (const [k, v] of Object.entries(query)) {
32 + if (v === undefined || v === null || v === '') continue;
33 + p.set(k, String(v));
34 + }
35 + const s = p.toString();
36 + return s ? `?${s}` : '';
37 +}
38 +
39 +async function adminFetch<T>(path: string, opts: { method?: 'GET' | 'POST'; body?: unknown; query?: Query } = {}): Promise<T> {
40 + const token = process.env.SI_ADMIN_TOKEN ?? '';
41 + if (!token) throw new AdminApiError(500, path, 'SI_ADMIN_TOKEN is not configured on the server');
42 + const url = `${BASE}${path}${qs(opts.query)}`;
43 + let res: Response;
44 + try {
45 + res = await fetch(url, {
46 + method: opts.method ?? 'GET',
47 + cache: 'no-store',
48 + headers: { accept: 'application/json', 'x-si-admin-token': token, ...(opts.body !== undefined ? { 'content-type': 'application/json' } : {}) },
49 + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
50 + });
51 + } catch (e) {
52 + throw new AdminApiError(0, path, `Admin API unreachable: ${(e as Error).message}`);
53 + }
54 + if (!res.ok) {
55 + let detail = `Admin API ${res.status} on ${path}`;
56 + try {
57 + const body = (await res.json()) as { error?: { title?: string; detail?: string | null } };
58 + detail = body.error?.detail || body.error?.title || detail;
59 + } catch {
60 + /* non-JSON body */
61 + }
62 + throw new AdminApiError(res.status, path, detail);
63 + }
64 + return (await res.json()) as T;
65 +}
66 +
67 +export const adminApi = {
68 + overview: () => adminFetch<Envelope<AdminOverview>>('/overview'),
69 + connectorRuns: (name: string, page = 1, pageSize = 50) => adminFetch<ConnectorRunsPayload>(`/connectors/${encodeURIComponent(name)}/runs`, { query: { page, page_size: pageSize } }),
70 + triggerRun: (name: string) => adminFetch<Envelope<{ queued: boolean; connector: string; note: string | null }>>(`/connectors/${encodeURIComponent(name)}/run`, { method: 'POST' }),
71 + setEnabled: (name: string, enabled: boolean) => adminFetch<Envelope<{ connector: string; enabled: boolean }>>(`/connectors/${encodeURIComponent(name)}/enabled`, { method: 'POST', body: { enabled } }),
72 + raw: (page = 1, connector?: string | null, pageSize = 50) => adminFetch<AdminPaginated<RawRecord>>('/raw', { query: { page, page_size: pageSize, connector } }),
73 + rawRecord: (id: string, maxBytes = 200_000) => adminFetch<Envelope<RawRecordDetail>>(`/raw/${encodeURIComponent(id)}`, { query: { max_bytes: maxBytes } }),
74 + dataQuality: (page = 1, flag?: string | null, pageSize = 50) => adminFetch<DataQualityPayload>('/data-quality', { query: { page, page_size: pageSize, flag } }),
75 + review: (page = 1, status = 'open', pageSize = 50) => adminFetch<AdminPaginated<ReviewItem>>('/review', { query: { page, page_size: pageSize, status } }),
76 + decide: (id: number, decision: ReviewDecision, by = 'admin') => adminFetch<Envelope<{ id: number; decision: string }>>(`/review/${id}`, { method: 'POST', body: { decision, by } }),
77 + costs: () => adminFetch<Envelope<CostsPayload>>('/costs'),
78 +};
79 +
80 +export async function safeAdmin<T>(p: Promise<T>): Promise<{ data: T; error: null } | { data: null; error: string }> {
81 + try {
82 + return { data: await p, error: null };
83 + } catch (e) {
84 + return { data: null, error: e instanceof Error ? e.message : String(e) };
85 + }
86 +}
added apps/web/src/lib/admin-auth.ts +59 −0
@@ -0,0 +1,59 @@
1 +import 'server-only';
2 +import { createHash, timingSafeEqual } from 'node:crypto';
3 +import { cookies } from 'next/headers';
4 +import { redirect } from 'next/navigation';
5 +
6 +/**
7 + * Admin session: the operator posts the admin token once (`/api/admin/login`); we compare it server-side with
8 + * `SI_ADMIN_TOKEN` and set an httpOnly cookie holding sha256(token). Every admin request re-derives the expected hash
9 + * and compares in constant time. The raw token never reaches the browser (all FastAPI calls happen in `admin-api.ts`).
10 + */
11 +export const ADMIN_COOKIE = 'si_admin';
12 +export const ADMIN_SESSION_SECONDS = 12 * 3600;
13 +
14 +export function adminToken(): string {
15 + return process.env.SI_ADMIN_TOKEN ?? '';
16 +}
17 +
18 +export function hashToken(token: string): string {
19 + return createHash('sha256').update(token, 'utf8').digest('hex');
20 +}
21 +
22 +function safeEqual(a: string, b: string): boolean {
23 + const ba = Buffer.from(a, 'utf8');
24 + const bb = Buffer.from(b, 'utf8');
25 + return ba.length === bb.length && timingSafeEqual(ba, bb);
26 +}
27 +
28 +/** True when `candidate` is the configured admin token (a blank server token disables the admin entirely). */
29 +export function verifyToken(candidate: string | null | undefined): boolean {
30 + const expected = adminToken();
31 + if (!expected || !candidate) return false;
32 + return safeEqual(hashToken(candidate), hashToken(expected));
33 +}
34 +
35 +export function verifyCookieValue(value: string | null | undefined): boolean {
36 + const expected = adminToken();
37 + if (!expected || !value) return false;
38 + return safeEqual(value, hashToken(expected));
39 +}
40 +
41 +export async function isAdmin(): Promise<boolean> {
42 + const jar = await cookies();
43 + return verifyCookieValue(jar.get(ADMIN_COOKIE)?.value);
44 +}
45 +
46 +/** Server components / route handlers: redirect anonymous visitors to the login form. */
47 +export async function requireAdmin(next?: string): Promise<void> {
48 + if (!(await isAdmin())) redirect(next ? `/admin/login?next=${encodeURIComponent(next)}` : '/admin/login');
49 +}
50 +
51 +export function cookieOptions(): { httpOnly: true; sameSite: 'lax'; secure: boolean; path: string; maxAge: number } {
52 + return { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: ADMIN_SESSION_SECONDS };
53 +}
54 +
55 +/** Only allow same-site relative redirects after login. */
56 +export function safeNextPath(next: string | null | undefined): string {
57 + if (!next || !next.startsWith('/admin') || next.startsWith('//')) return '/admin';
58 + return next;
59 +}
modified apps/web/src/lib/types.ts +1 −0
@@ -440,6 +440,7 @@ export interface SearchResult {
440 440 slug: string;
441 441 title: string;
442 442 subtitle: string | null;
443 + norad_id?: number | null;
443 444 score: number;
444 445 href: string;
445 446 }
modified src/satelliteindex/analytics/stats.py +1 −1
@@ -40,7 +40,7 @@ async def compute_global_stats(conn: AsyncConnection) -> dict[str, Any]:
40 40 count(distinct operator_id) filter (where status = 'ACTIVE') as active_operators,
41 41 count(distinct country_code) filter (where status = 'ACTIVE') as active_countries,
42 42 count(distinct constellation_id) filter (where status = 'ACTIVE') as active_constellations,
43 − max(latest_epoch) as latest_epoch
43 + max(latest_epoch) filter (where latest_epoch <= now() + interval '1 day') as latest_epoch
44 44 from satellites""")
45 45 launches = await fetch_one(conn, """select count(*) as total, count(*) filter (where launch_date >= current_date - 365) as last_365d,
46 46 count(*) filter (where launch_date >= date_trunc('year', current_date)) as ytd,
modified src/satelliteindex/api/common.py +22 −0
@@ -91,3 +91,25 @@ def json_response(payload: Any, status: int = 200, cache_s: int | None = None) -
91 91 if cache_s:
92 92 headers["Cache-Control"] = f"public, max-age={min(cache_s, 60)}, s-maxage={cache_s}, stale-while-revalidate={cache_s * 2}"
93 93 return ORJSONResponse(payload, status_code=status, headers=headers)
94 +
95 +
96 +def parse_date(v: str): # type: ignore[no-untyped-def]
97 + """Accept YYYY-MM-DD or ISO datetime; asyncpg needs real date/datetime objects."""
98 + from datetime import UTC, date, datetime
99 + v = v.strip().replace("Z", "+00:00")
100 + try:
101 + return date.fromisoformat(v) if len(v) == 10 else datetime.fromisoformat(v).astimezone(UTC).date()
102 + except ValueError as exc:
103 + raise ApiProblem(422, "Invalid date", f"{v!r} is not YYYY-MM-DD") from exc
104 +
105 +
106 +def parse_ts(v: str): # type: ignore[no-untyped-def]
107 + from datetime import UTC, datetime, time
108 + v = v.strip().replace("Z", "+00:00")
109 + try:
110 + dt = datetime.fromisoformat(v)
111 + except ValueError as exc:
112 + raise ApiProblem(422, "Invalid timestamp", f"{v!r} is not ISO 8601") from exc
113 + if dt.tzinfo is None:
114 + dt = datetime.combine(dt.date(), dt.time() or time.min, UTC)
115 + return dt
modified src/satelliteindex/api/routers/events.py +2 −2
@@ -4,7 +4,7 @@ from typing import Any
4 4
5 5 from fastapi import APIRouter, Depends, Request
6 6
7 −from satelliteindex.api.common import ApiProblem, Page, envelope, paginated
7 +from satelliteindex.api.common import ApiProblem, Page, envelope, paginated, parse_ts
8 8 from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
9 9
10 10 router = APIRouter(prefix="/api/v1", tags=["events"])
@@ -23,7 +23,7 @@ async def list_events(request: Request, page: Page = Depends(), type: str | None
23 23 if type:
24 24 where.append("e.type = any(:types)"); params["types"] = [t.upper() for t in type.split(",")]
25 25 if since:
26 − where.append("e.event_time >= :since"); params["since"] = since
26 + where.append("e.event_time >= :since"); params["since"] = parse_ts(since)
27 27 if entity:
28 28 where.append("exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = :ent)"); params["ent"] = entity
29 29 async with connection() as conn:
modified src/satelliteindex/api/routers/launches.py +16 −7
@@ -5,7 +5,7 @@ from typing import Any
5 5
6 6 from fastapi import APIRouter, Depends, Query, Request
7 7
8 −from satelliteindex.api.common import ApiProblem, Page, cached, envelope, paginated
8 +from satelliteindex.api.common import ApiProblem, Page, cached, envelope, paginated, parse_date
9 9 from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
10 10
11 11 router = APIRouter(prefix="/api/v1", tags=["launches"])
@@ -18,8 +18,12 @@ L_FROM = "from launches l left join launch_sites ls on ls.code = l.launch_site_c
18 18 @router.get("/launches")
19 19 async def list_launches(request: Request, page: Page = Depends(), year: int | None = None, site: str | None = None, country: str | None = None,
20 20 owner: str | None = None, min_payloads: int | None = None, after: str | None = None, before: str | None = None, q: str | None = None,
21 − sort: str = "date") -> dict[str, Any]:
21 + constellation: str | None = None, operator: str | None = None, sort: str = "date") -> dict[str, Any]:
22 22 where, params = ["true"], {}
23 + if constellation:
24 + where.append("exists (select 1 from satellites s join constellations k on k.id = s.constellation_id where s.launch_id = l.id and k.slug = :kslug)"); params["kslug"] = constellation
25 + if operator:
26 + where.append("exists (select 1 from satellites s join organizations o on o.id = s.operator_id where s.launch_id = l.id and o.slug = :oslug)"); params["oslug"] = operator
23 27 if year:
24 28 where.append("l.launch_year = :year"); params["year"] = year
25 29 if site:
@@ -31,9 +35,9 @@ async def list_launches(request: Request, page: Page = Depends(), year: int | No
31 35 if min_payloads:
32 36 where.append("l.payload_count >= :mp"); params["mp"] = min_payloads
33 37 if after:
34 − where.append("l.launch_date >= :after"); params["after"] = after
38 + where.append("l.launch_date >= :after"); params["after"] = parse_date(after)
35 39 if before:
36 − where.append("l.launch_date <= :before"); params["before"] = before
40 + where.append("l.launch_date <= :before"); params["before"] = parse_date(before)
37 41 if q:
38 42 where.append("(l.primary_name ilike :q or l.cospar_launch_id ilike :q)"); params["q"] = f"%{q}%"
39 43 order = {"date": "l.launch_date desc nulls last, l.cospar_launch_id desc", "-date": "l.launch_date asc", "payloads": "l.payload_count desc", "objects": "l.object_count desc"}.get(sort, "l.launch_date desc nulls last")
@@ -82,11 +86,11 @@ async def launch_detail(cospar: str, request: Request) -> dict[str, Any]:
82 86 async def launch_sites(request: Request) -> dict[str, Any]:
83 87 async def produce() -> list[dict[str, Any]]:
84 88 async with connection() as conn:
85 − return await fetch_all(conn, """select ls.code, ls.name, ls.slug, ls.country_code, c.name as country_name, ls.latitude, ls.longitude, count(l.id) as launches,
89 + return await fetch_all(conn, """select ls.code, ls.name, ls.slug, ls.country_code, c.name as country_name, c.slug as country_slug, ls.latitude, ls.longitude, count(l.id) as launches,
86 90 count(l.id) filter (where l.launch_date >= current_date - 365) as launches_last_365d, sum(l.payload_count) as payloads,
87 91 min(l.launch_date) as first_launch, max(l.launch_date) as last_launch
88 92 from launch_sites ls left join countries c on c.code = ls.country_code left join launches l on l.launch_site_code = ls.code
89 − group by 1,2,3,4,5,6,7 order by launches desc""")
93 + group by 1,2,3,4,5,6,7,8 order by launches desc""")
90 94
91 95 return envelope(await cached("launch-sites", 900, produce), request)
92 96
@@ -94,7 +98,12 @@ async def launch_sites(request: Request) -> dict[str, Any]:
94 98 @router.get("/launch-sites/{slug}")
95 99 async def launch_site_detail(slug: str, request: Request) -> dict[str, Any]:
96 100 async with connection() as conn:
97 − s = await fetch_one(conn, """select ls.*, c.name as country_name, c.slug as country_slug from launch_sites ls left join countries c on c.code = ls.country_code where ls.slug = :s or ls.code = :u""", s=slug, u=slug.upper())
101 + s = await fetch_one(conn, """select ls.*, c.name as country_name, c.slug as country_slug,
102 + (select count(*) from launches l where l.launch_site_code = ls.code) as launches,
103 + (select count(*) from launches l where l.launch_site_code = ls.code and l.launch_date >= current_date - 365) as launches_last_365d,
104 + (select max(l.launch_date) from launches l where l.launch_site_code = ls.code) as last_launch,
105 + (select min(l.launch_date) from launches l where l.launch_site_code = ls.code) as first_launch
106 + from launch_sites ls left join countries c on c.code = ls.country_code where ls.slug = :s or ls.code = :u""", s=slug, u=slug.upper())
98 107 if s is None:
99 108 raise ApiProblem(404, "Launch site not found", slug)
100 109 years = await fetch_all(conn, "select launch_year as year, count(*) as launches, sum(payload_count) as payloads from launches where launch_site_code = :c and launch_year is not null group by 1 order by 1", c=s["code"])
modified src/satelliteindex/api/routers/satellites.py +15 −6
@@ -6,13 +6,16 @@ from typing import Any
6 6
7 7 from fastapi import APIRouter, Depends, Query, Request
8 8
9 −from satelliteindex.api.common import ApiProblem, Page, cached, envelope, freshness_status, paginated
9 +from satelliteindex.api.common import ApiProblem, Page, cached, envelope, freshness_status, paginated, parse_date
10 10 from satelliteindex.api.ratelimit import limiter
11 11 from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
12 12 from satelliteindex.orbital.propagate import Elements, ground_track, propagate_one
13 13 from satelliteindex.services.positions import positions
14 14
15 15 router = APIRouter(prefix="/api/v1", tags=["satellites"])
16 +import re
17 +
18 +COSPAR_ID_RE = re.compile(r"^(19|20)\d{2}-\d{3}[A-Z]{1,3}$", re.I)
16 19
17 20 SAT_COLS = """s.id, s.slug, s.canonical_name as name, s.norad_id, s.cospar_id, s.object_type, s.status, s.ops_status_code, s.mission_type, s.orbit_class,
18 21 s.period_minutes, s.inclination_deg, s.apogee_km, s.perigee_km, s.rcs_m2, s.launch_date, s.decay_date, s.launch_site_code, s.has_gp, s.latest_epoch,
@@ -58,11 +61,11 @@ def _filters(q: dict[str, Any]) -> tuple[str, dict[str, Any]]:
58 61 if q.get("has_gp") is not None:
59 62 where.append("s.has_gp = :has_gp"); params["has_gp"] = q["has_gp"]
60 63 if q.get("launched_after"):
61 − where.append("s.launch_date >= :la"); params["la"] = q["launched_after"]
64 + where.append("s.launch_date >= :la"); params["la"] = parse_date(q["launched_after"])
62 65 if q.get("launched_before"):
63 − where.append("s.launch_date <= :lb"); params["lb"] = q["launched_before"]
66 + where.append("s.launch_date <= :lb"); params["lb"] = parse_date(q["launched_before"])
64 67 if q.get("decayed_after"):
65 − where.append("s.decay_date >= :da"); params["da"] = q["decayed_after"]
68 + where.append("s.decay_date >= :da"); params["da"] = parse_date(q["decayed_after"])
66 69 if q.get("min_perigee") is not None:
67 70 where.append("s.perigee_km >= :minp"); params["minp"] = q["min_perigee"]
68 71 if q.get("max_perigee") is not None:
@@ -117,6 +120,10 @@ async def _load(conn, ident: str) -> dict[str, Any] | None: # type: ignore[no-u
117 120 row = await fetch_one(conn, f"select {SAT_COLS} {SAT_FROM} where s.slug = :s or s.id = :s", s=ident)
118 121 if row:
119 122 return row
123 + if COSPAR_ID_RE.match(ident):
124 + row = await fetch_one(conn, f"select {SAT_COLS} {SAT_FROM} where s.cospar_id = :c order by s.norad_id limit 1", c=ident.upper())
125 + if row:
126 + return row
120 127 # slug alias → redirect target
121 128 alias = await fetch_one(conn, "select satellite_id from satellite_slugs where slug = :s", s=ident)
122 129 if alias:
@@ -152,9 +159,9 @@ async def satellite_detail(ident: str, request: Request) -> dict[str, Any]:
152 159 related = await fetch_all(conn, f"""select s.id, s.slug, s.canonical_name as name, s.norad_id, s.status, s.perigee_km {SAT_FROM}
153 160 where s.constellation_id = :k and s.id <> :s and s.status = 'ACTIVE' order by abs(coalesce(s.norad_id,0) - :n) limit 12""",
154 161 k=sat["constellation_id"], s=sid, n=sat["norad_id"] or 0) if sat["constellation_id"] else []
155 − sources = await fetch_all(conn, """select distinct src.id, src.name, src.official, src.attribution_text, c.last_success_at
162 + sources = await fetch_all(conn, """select src.id, src.name, src.official, src.attribution_text, max(c.last_success_at) as last_success_at
156 163 from field_provenance p join sources src on src.id = p.source_id left join connectors c on c.source_id = src.id
157 − where p.entity_type = 'satellite' and p.entity_id = :s""", s=sid)
164 + where p.entity_type = 'satellite' and p.entity_id = :s group by src.id, src.name, src.official, src.attribution_text order by src.priority desc""", s=sid)
158 165 live = None
159 166 if state:
160 167 el = Elements(satellite_id=sid, norad_id=sat["norad_id"], epoch=state["epoch"], mean_motion=state["mean_motion"], eccentricity=state["eccentricity"],
@@ -248,4 +255,6 @@ async def satellite_live(ident: str, request: Request) -> dict[str, Any]:
248 255 if p is None:
249 256 raise ApiProblem(404, "No orbital elements", "not in the live propagator set")
250 257 p.pop("position_teme_km", None); p.pop("velocity_teme_km_s", None)
258 + p["timestamp"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
259 + p["epoch_age_hours"] = round((datetime.now(UTC) - datetime.fromisoformat(p["source_epoch"])).total_seconds() / 3600, 1)
251 260 return envelope(p, request)
modified src/satelliteindex/api/routers/search.py +4 −4
@@ -38,7 +38,7 @@ async def search(request: Request, q: str = Query(..., min_length=1, max_length=
38 38 # exact identifier hits first
39 39 if term.isdigit():
40 40 rows = await fetch_all(conn, """select 'satellite' as entity_type, id as entity_id, slug, canonical_name as title,
41 − concat_ws(' · ', object_type, 'NORAD ' || norad_id, cospar_id, status) as subtitle, 100.0 as score
41 + concat_ws(' · ', object_type, 'NORAD ' || norad_id, cospar_id, status) as subtitle, 100.0 as score, norad_id
42 42 from satellites where norad_id = :n""", n=int(term))
43 43 results += rows
44 44 if COSPAR_RE.match(term):
@@ -46,7 +46,7 @@ async def search(request: Request, q: str = Query(..., min_length=1, max_length=
46 46 if "-" not in norm:
47 47 norm = norm[:4] + "-" + norm[4:]
48 48 rows = await fetch_all(conn, """select 'satellite' as entity_type, id as entity_id, slug, canonical_name as title,
49 − concat_ws(' · ', object_type, 'NORAD ' || norad_id, cospar_id, status) as subtitle, 95.0 as score
49 + concat_ws(' · ', object_type, 'NORAD ' || norad_id, cospar_id, status) as subtitle, 95.0 as score, norad_id
50 50 from satellites where cospar_id ilike :c order by cospar_id limit 20""", c=norm + "%")
51 51 results += rows
52 52 if len(norm) == 8:
@@ -57,12 +57,12 @@ async def search(request: Request, q: str = Query(..., min_length=1, max_length=
57 57 tsq = " & ".join(f"{w}:*" for w in re.findall(r"[A-Za-z0-9]+", term)[:6]) or term
58 58 type_sql = " and entity_type = any(:types)" if type_filter else ""
59 59 rows = await fetch_all(conn, f"""
60 − select entity_type, entity_id, slug, title, subtitle,
60 + select si.entity_type, si.entity_id, si.slug, si.title, si.subtitle, sat.norad_id,
61 61 (case when lower(title) = lower(:t) then 50 else 0 end
62 62 + case when title ilike :prefix then 20 else 0 end
63 63 + coalesce(ts_rank_cd(tsv, to_tsquery('simple', :tsq)), 0) * 10
64 64 + similarity(title, :t) * 15 + weight) as score
65 − from search_index
65 + from search_index si left join satellites sat on si.entity_type = 'satellite' and sat.id = si.entity_id
66 66 where (tsv @@ to_tsquery('simple', :tsq) or title ilike :like or keywords ilike :like or title % :t) {type_sql}
67 67 order by score desc, title limit :lim""", t=term, prefix=term + "%", like="%" + term + "%", tsq=tsq, lim=limit, types=type_filter)
68 68 seen = {(r["entity_type"], r["entity_id"]) for r in results}
modified src/satelliteindex/connectors/derived.py +29 −0
@@ -28,6 +28,35 @@ class DerivedAnalyticsConnector(BaseConnector):
28 28 await execute(conn, """update data_quality_flags f set resolved_at = now() from satellites s
29 29 where f.entity_type = 'satellite' and f.entity_id = s.id and f.flag = 'STALE_DATA' and f.resolved_at is null
30 30 and (s.latest_epoch >= now() - interval '30 days' or s.status <> 'ACTIVE')""")
31 + # catalog quality flags (cheap set-based passes)
32 + await execute(conn, """
33 + insert into data_quality_flags (entity_type, entity_id, flag, detail)
34 + select 'satellite', id, 'UNKNOWN_COUNTRY', 'SATCAT owner ' || coalesce(owner_code, 'missing') from satellites
35 + where decay_date is null and object_type in ('PAYLOAD','STATION') and (owner_code is null or owner_code in ('UNK','TBD'))
36 + on conflict (entity_type, entity_id, flag) do update set resolved_at = null""")
37 + await execute(conn, """
38 + insert into data_quality_flags (entity_type, entity_id, flag, detail)
39 + select 'satellite', id, 'MISSING_ID', 'no COSPAR international designator' from satellites where cospar_id is null
40 + on conflict (entity_type, entity_id, flag) do update set resolved_at = null""")
41 + await execute(conn, """
42 + insert into data_quality_flags (entity_type, entity_id, flag, detail)
43 + select 'satellite', id, 'UNKNOWN_OPERATOR', 'active payload without resolved operator' from satellites
44 + where status = 'ACTIVE' and object_type in ('PAYLOAD','STATION') and operator_id is null
45 + on conflict (entity_type, entity_id, flag) do update set resolved_at = null""")
46 + await execute(conn, """update data_quality_flags f set resolved_at = now() from satellites s
47 + where f.entity_type = 'satellite' and f.entity_id = s.id and f.resolved_at is null
48 + and ((f.flag = 'UNKNOWN_OPERATOR' and s.operator_id is not null) or (f.flag = 'MISSING_ID' and s.cospar_id is not null)
49 + or (f.flag = 'UNKNOWN_COUNTRY' and s.owner_code is not null and s.owner_code not in ('UNK','TBD')))""")
50 + # membership history backfill: every satellite with a constellation must have an open membership row
51 + await execute(conn, """
52 + insert into constellation_memberships (satellite_id, constellation_id, method)
53 + select s.id, s.constellation_id,
54 + case when exists (select 1 from satellite_tags t join constellations k on k.id = s.constellation_id
55 + where t.satellite_id = s.id and k.celestrak_groups ? t.tag) then 'celestrak_group' else 'name_pattern' end
56 + from satellites s where s.constellation_id is not null
57 + and not exists (select 1 from constellation_memberships m where m.satellite_id = s.id and m.constellation_id = s.constellation_id and m.until is null)""")
58 + await execute(conn, """update constellation_memberships m set until = now() from satellites s
59 + where m.satellite_id = s.id and m.until is null and s.constellation_id is distinct from m.constellation_id""")
31 60 # duplicate-name payload pairs (same normalized name, both on orbit, different NORAD) → review queue (never auto-merge)
32 61 await execute(conn, """
33 62 insert into manual_review_queue (kind, entity_a_type, entity_a_id, entity_b_type, entity_b_id, confidence, detail)
modified src/satelliteindex/connectors/orbital/celestrak/gp.py +2 −0
@@ -30,6 +30,8 @@ def gp_url(group: str, config: dict[str, Any] | None = None) -> str:
30 30
31 31
32 32 def parse_omm(text: str) -> list[dict[str, Any]]:
33 + if text.lstrip().startswith("No GP data"):
34 + return [] # CelesTrak answers "No GP data found" (text/plain) for empty groups
33 35 data = json.loads(text)
34 36 if not isinstance(data, list):
35 37 raise ValueError("OMM payload is not a JSON array")
36 38