SPB Git

SatelliteIndex v0.1 — backend (CelesTrak connectors, SGP4, FastAPI), web scaffolding, deploy manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) Browse files

128 changed files +13,601 −0

128 changed files
added .env.example +24 −0
@@ -0,0 +1,24 @@
1+# SatelliteIndex — copy to .env (never commit .env). Production values live only in the mld manifest on M1M32.
2+APP_ENV=development
3+APP_DOMAIN=www.satelliteindex.io
4+SI_SITE_URL=https://www.satelliteindex.io
5+
6+DATABASE_URL=postgresql+asyncpg://localhost:5432/satelliteindex
7+REDIS_URL=redis://127.0.0.1:6379/4
8+
9+SI_DATA_DIR=./data
10+SI_API_HOST=127.0.0.1
11+SI_API_PORT=8311
12+SI_ADMIN_TOKEN=change-me
13+SI_LOG_JSON=1
14+
15+# Future connectors (leave empty until enabled)
16+SPACE_TRACK_USERNAME=
17+SPACE_TRACK_PASSWORD=
18+DISCOS_API_KEY=
19+SCRAPFLY_API_KEY=
20+FIRECRAWL_API_KEY=
21+
22+# Web (apps/web)
23+API_URL=http://127.0.0.1:8311
24+NEXT_PUBLIC_SITE_URL=https://www.satelliteindex.io
added .gitignore +16 −0
@@ -0,0 +1,16 @@
1+.env
2+.venv/
3+__pycache__/
4+*.pyc
5+data/
6+node_modules/
7+.next/
8+tmp/
9+.DS_Store
10+deploy/.admin-token
11+*.tsbuildinfo
12+apps/web/next-env.d.ts
13+apps/web/qa/screens/
14+.pytest_cache/
15+.ruff_cache/
16+deploy/rendered/
added CLAUDE.md +60 −0
@@ -0,0 +1,60 @@
1+# SatelliteIndex.io — project guide (condensed from the founding spec, see docs/SPEC-summary.md)
2+
3+**Mission**: the definitive public intelligence layer for everything operating, launched, proposed, licensed, decaying or changing in
4+Earth orbit — Bloomberg Terminal × FlightRadar24 × Crunchbase for orbital infrastructure. Not a simple satellite tracker.
5+
6+## Architecture (never couple the website to third-party sources)
7+
8+```
9+External sources → connector workers → raw snapshots (SI_DATA_DIR/raw, gzip + raw_records) → normalization → entity resolution
10+→ canonical Postgres → derived analytics (matviews, stats_snapshots, search_index, events) → FastAPI /api/v1 → Next.js
11+```
12+
13+- **Backend** `src/satelliteindex/` (Python 3.12, FastAPI, SQLAlchemy Core + asyncpg, Alembic, sgp4, APScheduler, Redis).
14+ - `connectors/` framework (`BaseConnector.run()` = bookkeeping, hashing, circuit breaker; `execute()` per connector) — CelesTrak GP
15+ (`celestrak_gp`), CelesTrak groups (`celestrak_groups`), SATCAT (`celestrak_satcat`), `derived_analytics`. Registry: `connectors/__init__.py`.
16+ - `orbital/` SGP4 (vectorised `SatrecArray`), TEME→geodetic, orbit classification (documented metric `orbit_class`).
17+ - `services/` entity resolution (`SatIndex`, NORAD → COSPAR → name; never auto-merge ambiguous), classification (curated
18+ `registry/constellations.yaml`), positions service (in-memory propagator, 30 s cache), events (dedupe keys), Redis cache/locks.
19+ - `api/routers/` satellites, search, stats/rankings/density, orbit positions, constellations, operators, countries, launches/sites/debris/reentries,
20+ events, misc (sources, status, methodology, sitemap, view beacon), admin (`x-si-admin-token`).
21+ - CLI `si`: `migrate | seed | run <connector> [--file group=path] | status | stats | position <norad> | schedule | api | backup`.
22+- **Frontend** `apps/web/` (Next 16, Tailwind v4, Three/R3F globe). Guide: `docs/FRONTEND-GUIDE.md`.
23+- **Database**: `migrations/versions/0001_initial.py` (plain SQL). Internal ids are prefixed ULIDs (`sat_…`); NORAD/COSPAR are *source* identifiers.
24+ Orbital history is append-only (`orbital_elements`), `orbital_state` = latest per object. Materialized views refresh hourly.
25+
26+## Hard rules (from the spec)
27+
28+- No hardcoded satellite counts, source results, timestamps, fake analytics or placeholder charts. Missing → "Unavailable".
29+- Never overwrite orbital history, discard provenance, merge ambiguous satellites silently, or delete entities because a source went quiet
30+ (empty/short upstream responses are *suspicious*, not "no data").
31+- Never expose credentials (Space-Track, DB, Scrapfly…) or internal cluster addresses. Admin behind token. Rate limits on search/positions.
32+- Derived analytics (orbit class, mission type, constellation membership, activity score, orbital density) are labelled derived with a
33+ documented, versioned methodology (`metric_definitions`, `/methodology`). Never present them as safety metrics. Never invent collision
34+ probabilities or precise reentry locations.
35+- Mobile-first (390/430/768/1440), no horizontal overflow, ≥ 44 px targets, DOM order = visual order. Design: premium, scientific,
36+ dark, data-dense — no crypto-dashboard look, no wall of rounded cards.
37+- Units: km, km/s, kg, degrees, minutes, UTC. ISO 3166 country codes.
38+- Every feature: real data, error + loading states, mobile + desktop, source attribution, performance, accessibility, logging, tests, deployment.
39+
40+## Local development
41+
42+```
43+uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -e ".[dev]" # backend deps
44+cp .env.example .env # local Postgres `satelliteindex`, Redis db 4
45+.venv/bin/si migrate && .venv/bin/si seed
46+.venv/bin/si run celestrak_satcat && .venv/bin/si run celestrak_gp && .venv/bin/si run celestrak_groups && .venv/bin/si run derived_analytics
47+.venv/bin/si api # http://127.0.0.1:8311 (docs at /api/v1/docs)
48+pnpm install && pnpm dev:web # http://localhost:8310
49+pytest # tests/ (fixtures in tests/fixtures, no live network by default)
50+```
51+
52+CelesTrak blocks a second download of the same GP group within 2 h (HTTP 403 "has not updated") — the connector treats it as *unchanged*;
53+`si run celestrak_gp --file active=/path/active.json` ingests a local snapshot.
54+
55+## Production (MacLustr)
56+
57+Deployed by `mld` on **M2M32b** (Mac Studio, dedicated): PM2 `satelliteindex-api` (uvicorn 127.0.0.1:8311), `satelliteindex-scheduler`
58+(`si schedule`), `satelliteindex-web` (Next :8310). Postgres 17 + Redis via Homebrew on the node; data in `~/satelliteindex-data/`
59+(raw snapshots, backups, logs). Public route: MacLustr Tunnel (BHS64 Caddy) `https://www.satelliteindex.io → M2M32b:8310`, apex redirected.
60+Manifest: `deploy/satelliteindex.mld.json` → `M1M32:~/dispatch/apps/satelliteindex.json`. See `docs/DEPLOY.md`.
added Makefile +45 −0
@@ -0,0 +1,45 @@
1+PY=.venv/bin/python
2+SI=.venv/bin/si
3+
4+.PHONY: dev backend frontend worker migrate seed ingest test lint typecheck build deploy backup
5+
6+dev: ## backend + frontend dev servers (two terminals recommended)
7+ @echo "run 'make backend' and 'make frontend' in two terminals"
8+
9+backend: ## FastAPI with reload on :8311
10+ $(SI) api --reload
11+
12+frontend: ## Next dev on :8310
13+ pnpm dev:web
14+
15+worker: ## scheduler process
16+ $(SI) schedule
17+
18+migrate:
19+ $(SI) migrate
20+
21+seed:
22+ $(SI) seed
23+
24+ingest: ## full ingestion chain
25+ $(SI) run celestrak_satcat; $(SI) run celestrak_gp; $(SI) run celestrak_groups; $(SI) run derived_analytics
26+
27+test:
28+ $(PY) -m pytest -q
29+
30+lint:
31+ .venv/bin/ruff check src tests
32+
33+typecheck:
34+ cd apps/web && pnpm typecheck
35+
36+build:
37+ pnpm build
38+
39+deploy: ## stage + deploy through mld (see docs/DEPLOY.md)
40+ deploy/render-manifest.sh --push
41+ ~/Desktop/cluster-skill/mld stage $(CURDIR) satelliteindex
42+ ~/Desktop/cluster-skill/mld deploy satelliteindex --node M2M32b
43+
44+backup:
45+ $(SI) backup
added README.md +35 −0
@@ -0,0 +1,35 @@
1+# SatelliteIndex.io
2+
3+**The world's orbital infrastructure, mapped and indexed.** A public intelligence layer for everything in Earth orbit: satellites,
4+constellations, operators, countries, launches, debris and reentries — with live SGP4 positions, orbital history, derived analytics
5+and transparent sources. Live at https://www.satelliteindex.io.
6+
7+- **Backend** (`src/satelliteindex`): Python 3.12 · FastAPI · PostgreSQL 17 · Redis · python-sgp4 · APScheduler. Connector framework with
8+ raw-snapshot preservation, content hashing, circuit breakers, entity resolution (prefixed ULIDs; NORAD/COSPAR are source ids),
9+ append-only orbital history, field provenance, derived analytics (materialized views, stats snapshots, search index, events).
10+- **Frontend** (`apps/web`): Next 16 · React 19 · Tailwind v4 · React Three Fiber globe (GPU point cloud, 16 k+ objects) · SVG charts & maps.
11+- **Sources (v0.1)**: CelesTrak GP element sets (OMM JSON, every 2 h), CelesTrak thematic groups, CelesTrak SATCAT (daily).
12+ Planned: Space-Track, GCAT, UNOOSA, ESA DISCOS, regulators (FCC/ITU/ISED/Ofcom/ARCEP), company monitoring.
13+
14+## Quick start
15+
16+```bash
17+uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -e ".[dev]"
18+cp .env.example .env # DATABASE_URL=postgresql+asyncpg://localhost:5432/satelliteindex, REDIS_URL=redis://127.0.0.1:6379/4
19+createdb satelliteindex
20+.venv/bin/si migrate && .venv/bin/si seed
21+make ingest # SATCAT (~70 k objects) → GP (~16 k element sets) → groups → analytics
22+.venv/bin/si api # http://127.0.0.1:8311/api/v1/docs
23+pnpm install && pnpm dev:web # http://localhost:8310
24+pytest -q
25+```
26+
27+Docs: `CLAUDE.md` (project rules), `docs/FRONTEND-GUIDE.md`, `docs/DEPLOY.md`, `docs/API.md`, `/methodology` on the site.
28+
29+## Attribution & disclaimer
30+
31+Orbital data courtesy of CelesTrak (Dr. T.S. Kelso). SatelliteIndex.io is an informational platform: positions, predictions and
32+derived metrics may contain delays or uncertainty and must not be used as the sole source for safety-critical, navigation,
33+mission-control, military or operational decisions.
34+
35+© 2026 Simon-Pierre Boucher · contact@spboucher.ai · hosted on [MacLustr](https://www.maclustr.io).
added alembic.ini +31 −0
@@ -0,0 +1,31 @@
1+[alembic]
2+script_location = migrations
3+prepend_sys_path = src
4+path_separator = os
5+
6+[loggers]
7+keys = root,alembic
8+
9+[handlers]
10+keys = console
11+
12+[formatters]
13+keys = generic
14+
15+[logger_root]
16+level = WARN
17+handlers = console
18+
19+[logger_alembic]
20+level = INFO
21+handlers =
22+qualname = alembic
23+
24+[handler_console]
25+class = StreamHandler
26+args = (sys.stderr,)
27+level = NOTSET
28+formatter = generic
29+
30+[formatter_generic]
31+format = %(levelname)-5.5s [%(name)s] %(message)s
added apps/web/next.config.ts +65 −0
@@ -0,0 +1,65 @@
1+import type { NextConfig } from 'next';
2+import { existsSync } from 'node:fs';
3+import path from 'node:path';
4+
5+// Monorepo: a single `.env` lives at the repository root; Next only reads the app directory.
6+for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) {
7+ if (existsSync(candidate)) {
8+ try {
9+ process.loadEnvFile(candidate);
10+ } catch {
11+ /* ignore malformed env */
12+ }
13+ }
14+}
15+
16+const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8311';
17+
18+const nextConfig: NextConfig = {
19+ reactStrictMode: true,
20+ poweredByHeader: false,
21+ allowedDevOrigins: ['127.0.0.1', 'localhost'],
22+ outputFileTracingRoot: path.resolve(__dirname, '../..'),
23+ experimental: {
24+ optimizePackageImports: ['lucide-react', 'three', '@react-three/drei'],
25+ },
26+ // Browser-side fetches go to the same origin; the FastAPI service is loopback-only.
27+ async rewrites() {
28+ return [
29+ { source: '/api/v1/:path*', destination: `${API_URL}/api/v1/:path*` },
30+ { source: '/health', destination: `${API_URL}/health` },
31+ ];
32+ },
33+ async redirects() {
34+ return [
35+ { source: '/satellites/:slug((?!facets$)[^/]+)', destination: '/satellite/:slug', permanent: true },
36+ { source: '/constellations/:slug', destination: '/constellation/:slug', permanent: true },
37+ { source: '/operators/:slug', destination: '/operator/:slug', permanent: true },
38+ { source: '/countries/:slug', destination: '/country/:slug', permanent: true },
39+ ];
40+ },
41+ async headers() {
42+ const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=600, stale-while-revalidate=3600' };
43+ const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' };
44+ return [
45+ {
46+ source: '/(.*)',
47+ headers: [
48+ { key: 'X-Content-Type-Options', value: 'nosniff' },
49+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
50+ { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
51+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
52+ { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
53+ ],
54+ },
55+ { source: '/satellite/:path*', headers: [PUBLIC_CACHE] },
56+ { source: '/constellation/:path*', headers: [PUBLIC_CACHE] },
57+ { source: '/operator/:path*', headers: [PUBLIC_CACHE] },
58+ { source: '/country/:path*', headers: [PUBLIC_CACHE] },
59+ { source: '/api/:path*', headers: [NO_STORE] },
60+ { source: '/admin/:path*', headers: [NO_STORE] },
61+ ];
62+ },
63+};
64+
65+export default nextConfig;
added apps/web/package.json +44 −0
@@ -0,0 +1,44 @@
1+{
2+ "name": "@satelliteindex/web",
3+ "version": "0.1.0",
4+ "private": true,
5+ "scripts": {
6+ "dev": "next dev -p 8310",
7+ "build": "next build",
8+ "start": "next start -p 8310 -H 0.0.0.0",
9+ "typecheck": "tsc -p tsconfig.json --noEmit",
10+ "qa": "node qa/screens.mjs"
11+ },
12+ "dependencies": {
13+ "@react-three/drei": "^10.7.6",
14+ "@react-three/fiber": "^9.4.0",
15+ "d3-array": "^3.2.4",
16+ "d3-geo": "^3.1.1",
17+ "d3-scale": "^4.0.2",
18+ "d3-shape": "^3.2.0",
19+ "lucide-react": "^1.0.0",
20+ "next": "16.3.4",
21+ "react": "19.2.8",
22+ "react-dom": "19.2.8",
23+ "server-only": "^0.0.1",
24+ "three": "^0.182.0",
25+ "topojson-client": "^3.1.0",
26+ "world-atlas": "^2.0.2"
27+ },
28+ "devDependencies": {
29+ "@tailwindcss/postcss": "^4",
30+ "@types/d3-array": "^3.2.1",
31+ "@types/d3-geo": "^3.1.1",
32+ "@types/d3-scale": "^4.0.9",
33+ "@types/d3-shape": "^3.1.7",
34+ "@types/geojson": "^7946.0.16",
35+ "@types/node": "^24.0.0",
36+ "@types/react": "^19",
37+ "@types/react-dom": "^19",
38+ "@types/three": "^0.182.0",
39+ "@types/topojson-client": "^3.1.5",
40+ "@types/topojson-specification": "^1.0.5",
41+ "tailwindcss": "^4",
42+ "typescript": "^5.9.3"
43+ }
44+}
added apps/web/postcss.config.mjs +7 −0
@@ -0,0 +1,7 @@
1+const config = {
2+ plugins: {
3+ '@tailwindcss/postcss': {},
4+ },
5+};
6+
7+export default config;
added apps/web/public/icon.svg +1 −0
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#060912"/><circle cx="32" cy="32" r="14" fill="#eaf0ff"/><ellipse cx="32" cy="32" rx="27" ry="10" fill="none" stroke="#eaf0ff" stroke-width="3" transform="rotate(-24 32 32)" opacity=".85"/><circle cx="54" cy="19" r="4.5" fill="#38d3ff"/></svg>
added apps/web/public/robots.txt +7 −0
@@ -0,0 +1,7 @@
1+User-agent: *
2+Allow: /
3+Disallow: /admin
4+Disallow: /internal
5+Disallow: /debug
6+Disallow: /api/
7+Sitemap: https://www.satelliteindex.io/sitemap.xml
added apps/web/src/app/constellation/[slug]/page.tsx +163 −0
@@ -0,0 +1,163 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { DecaysByMonth, DeploymentTimeline, MembershipNote, OrbitalShells, StatusDonut } from '@/components/entities/constellation-panels';
5+import { Block, EventsList, ExternalLink, HeroFacts, KpiStrip, PlannedUnavailable, Tag, entityMetadata } from '@/components/entities/shared';
6+import { LaunchesTable, SatellitesTable, SitesTable } from '@/components/entities/tables';
7+import { OrbitBadge } from '@/components/ui/badges';
8+import { Container } from '@/components/ui/section';
9+import { Unavailable } from '@/components/ui/unavailable';
10+import { ApiError, api } from '@/lib/api';
11+import { fmt1, fmtDate, fmtDateTime, fmtInt, fmtPct, num, titleCase } from '@/lib/format';
12+import { SITE_URL, routes } from '@/lib/site';
13+import type { ConstellationDetail } from '@/lib/types';
14+
15+type Props = { params: Promise<{ slug: string }> };
16+
17+async function load(slug: string): Promise<{ d: ConstellationDetail; generatedAt: string } | null> {
18+ try {
19+ const res = await api.constellation(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: 'Constellation not found', robots: { index: false } };
31+ const { d } = r;
32+ const desc = `${d.name}${d.operator_name ? ` (${d.operator_name})` : ''}: ${fmtInt(d.active)} active satellites, ${fmtInt(d.total)} launched since ${fmtDate(d.first_launch)}. ${d.orbit_class ?? ''} ${titleCase(d.service_type)} constellation — deployment timeline, orbital shells, launch history and status.`;
33+ return entityMetadata({ title: `${d.name} — ${fmtInt(d.active)} active satellites, ${d.orbit_class ?? 'mixed'} constellation`, description: desc, path: routes.constellation(d.slug), ogImage: `${SITE_URL}${routes.constellation(d.slug)}/opengraph-image` });
34+}
35+
36+export default async function ConstellationPage({ params }: Props) {
37+ const { slug } = await params;
38+ const r = await load(slug);
39+ if (!r) notFound();
40+ const { d, generatedAt } = r;
41+ const active = num(d.active);
42+ const planned = d.planned_count;
43+ const plannedPct = planned && active !== null ? (active / planned) * 100 : null;
44+
45+ const jsonLd = {
46+ '@context': 'https://schema.org',
47+ '@type': 'Dataset',
48+ name: `${d.name} constellation`,
49+ description: d.description ?? `${d.name} satellite constellation tracked by SatelliteIndex`,
50+ url: `${SITE_URL}${routes.constellation(d.slug)}`,
51+ creator: d.operator_name ? { '@type': 'Organization', name: d.operator_name, url: d.operator_slug ? `${SITE_URL}${routes.operator(d.operator_slug)}` : undefined } : undefined,
52+ variableMeasured: ['active satellites', 'satellites on orbit', 'launches'],
53+ };
54+
55+ return (
56+ <Container wide>
57+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
58+
59+ {/* Hero */}
60+ <header className="pb-6 pt-8 md:pb-8 md:pt-12">
61+ <nav aria-label="Breadcrumb" className="eyebrow">
62+ <Link href={routes.constellations()} className="hover:text-ink">Constellations</Link> <span aria-hidden>/</span> {d.name}
63+ </nav>
64+ <div className="mt-3 flex flex-wrap items-center gap-2">
65+ <OrbitBadge orbitClass={d.orbit_class} />
66+ {d.service_type && <Tag>{titleCase(d.service_type)}</Tag>}
67+ <Tag tone={d.lifecycle_stage === 'OPERATIONAL' ? 'accent' : 'warn'}>{titleCase(d.lifecycle_stage.toLowerCase())}</Tag>
68+ </div>
69+ <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>
70+ <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">
71+ {d.operator_slug ? <Link href={routes.operator(d.operator_slug)} className="link">{d.operator_name}</Link> : d.operator_name ?? 'Operator unavailable'}
72+ {d.country_slug && (
73+ <>
74+ {' '}· <Link href={routes.country(d.country_slug)} className="link">{d.country_name}</Link>
75+ </>
76+ )}
77+ </p>
78+ {d.description && <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}
79+ <HeroFacts
80+ items={[
81+ { label: 'Official site', value: d.official_url ? <ExternalLink href={d.official_url} /> : null },
82+ { label: 'Deployment vs plan', value: planned ? `${fmtInt(active)} of ${fmtInt(planned)} planned (${fmtPct(plannedPct, 0)})` : null },
83+ { label: 'Authorized', value: d.authorized_count ? `${fmtInt(d.authorized_count)} satellites` : null },
84+ { label: 'Median orbit', value: num(d.median_perigee_km) !== null ? `${fmtInt(d.median_perigee_km)} km · ${fmt1(d.median_inclination_deg)}°` : null },
85+ ]}
86+ />
87+ </header>
88+
89+ <KpiStrip
90+ items={[
91+ { label: 'Active', value: <span className="text-active">{fmtInt(d.active)}</span> },
92+ { label: 'Inactive', value: fmtInt(d.inactive) },
93+ { label: 'Decayed', value: fmtInt(d.decayed) },
94+ { label: 'On orbit', value: fmtInt(d.on_orbit) },
95+ { label: 'Total launched', value: fmtInt(d.total) },
96+ { label: 'Launches', value: fmtInt(d.launches) },
97+ { label: 'Launched 30 d', value: fmtInt(d.launched_last_30d) },
98+ { label: 'Launched 365 d', value: fmtInt(d.launched_last_365d) },
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: 'Activity score', value: <span className="text-accent-2">{fmt1(d.activity_score)}</span>, derived: true, hint: 'launch cadence index' },
102+ { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },
103+ ]}
104+ />
105+
106+ {/* Terminal layout: main analysis + right telemetry column */}
107+ <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
108+ <div className="min-w-0">
109+ <Block eyebrow="Deployment" title="Deployment timeline" id="timeline">
110+ <DeploymentTimeline growth={d.growth} />
111+ </Block>
112+ <Block eyebrow="Orbits" title="Orbital shells" id="shells">
113+ <OrbitalShells d={d} />
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' }}>
116+ <LaunchesTable rows={d.launches_list} showActive />
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+ </Block>
119+ <Block eyebrow="Fleet" title="Recent satellites" id="satellites" action={{ href: routes.satellites(`constellation=${encodeURIComponent(d.slug)}`), label: 'All satellites in this constellation' }}>
120+ <SatellitesTable rows={d.recent_satellites} columns={['perigee', 'apogee', 'inclination']} />
121+ </Block>
122+ {d.decays_by_month.length > 0 && (
123+ <Block eyebrow="Reentries" title="Decays by month" id="decays">
124+ <DecaysByMonth rows={d.decays_by_month} />
125+ </Block>
126+ )}
127+ <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`constellation=${encodeURIComponent(d.slug)}`), label: 'All events' }}>
128+ <EventsList events={d.events} />
129+ </Block>
130+ </div>
131+
132+ <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">
133+ <Block eyebrow="Status" title="Status distribution">
134+ <StatusDonut dist={d.status_distribution} total={num(d.total)} />
135+ </Block>
136+ <Block eyebrow="Ground" title="Launch sites">
137+ <SitesTable rows={d.launch_sites} showSatellites />
138+ </Block>
139+ <Block eyebrow="Registry" title="Countries">
140+ {d.countries.length === 0 ? (
141+ <Unavailable what="Country attribution" compact />
142+ ) : (
143+ <ul className="divide-y divide-rule text-sm">
144+ {d.countries.map((c) => (
145+ <li key={c.code} className="flex items-center justify-between gap-3 py-2">
146+ <Link href={routes.country(c.slug)} className="link">{c.name} <span className="mono ml-1 text-xs text-ink-3">{c.code}</span></Link>
147+ <span className="tnum">{fmtInt(c.satellites)}</span>
148+ </li>
149+ ))}
150+ </ul>
151+ )}
152+ </Block>
153+ <Block eyebrow="Regulatory" title="Regulatory filings">
154+ <PlannedUnavailable what="Regulatory filings" note="FCC / ITU connectors are planned; filings will appear here with source attribution once ingested." />
155+ </Block>
156+ <Block eyebrow="Methodology" title="How membership is determined">
157+ <MembershipNote d={d} />
158+ </Block>
159+ </aside>
160+ </div>
161+ </Container>
162+ );
163+}
added apps/web/src/app/constellations/page.tsx +145 −0
@@ -0,0 +1,145 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ChipRow, Derived, entityMetadata, first, ScrollTable, EmptyRow, type Params } from '@/components/entities/shared';
4+import { OrbitBadge } from '@/components/ui/badges';
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 { fmt1, fmtDate, fmtInt, fmtKm, num, titleCase } from '@/lib/format';
10+import { routes } from '@/lib/site';
11+import type { ConstellationRow } from '@/lib/types';
12+
13+type Search = Record<string, string | string[] | undefined>;
14+
15+const SORTS: { value: string | undefined; label: string }[] = [
16+ { value: undefined, label: 'Active' },
17+ { value: 'total', label: 'Total' },
18+ { value: 'growth', label: 'Growth 365 d' },
19+ { value: 'activity', label: 'Activity' },
20+ { value: 'name', label: 'Name' },
21+];
22+const SERVICES = ['communications', 'earth-observation', 'navigation', 'iot', 'weather', 'military', 'technology', 'station', 'science'];
23+const ORBITS = ['LEO', 'MEO', 'GEO', 'HEO', 'MIXED'];
24+
25+function parse(sp: Search): Params {
26+ return { sort: first(sp.sort), service: first(sp.service), orbit: first(sp.orbit), page: first(sp.page) };
27+}
28+
29+export async function generateMetadata({ searchParams }: { searchParams: Promise<Search> }): Promise<Metadata> {
30+ const p = parse(await searchParams);
31+ const bits = [p.service && titleCase(p.service), p.orbit].filter(Boolean).join(' · ');
32+ return entityMetadata({
33+ title: bits ? `${bits} satellite constellations` : 'Satellite constellations — ranked by active satellites',
34+ description: 'Every tracked satellite constellation ranked by active satellites, fleet size, 365-day growth and activity score, with orbit class, operator, median altitude and launch history.',
35+ path: routes.constellations(),
36+ });
37+}
38+
39+export default async function ConstellationsPage({ searchParams }: { searchParams: Promise<Search> }) {
40+ const params = parse(await searchParams);
41+ const page = Math.max(1, Number(params.page) || 1);
42+ const res = await safe(api.constellations({ sort: params.sort, service: params.service, orbit: params.orbit, page }));
43+ const rows = res?.data ?? [];
44+ const total = res?.pagination.total ?? null;
45+ const base = routes.constellations();
46+ const activeSum = rows.reduce((s, r) => s + (num(r.active) ?? 0), 0);
47+
48+ return (
49+ <Container wide>
50+ <PageHeader
51+ eyebrow="Constellations"
52+ title="Satellite constellations"
53+ lede={
54+ total !== null ? (
55+ <>
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+ {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+ ) : (
60+ 'Constellation rankings are temporarily unavailable.'
61+ )
62+ }
63+ />
64+
65+ <div className="flex flex-col gap-2 border-y border-rule py-2">
66+ <ChipRow label="Sort" paramKey="sort" base={base} params={params} current={params.sort} options={SORTS} />
67+ <ChipRow label="Service" paramKey="service" base={base} params={params} current={params.service} options={[{ value: undefined, label: 'All' }, ...SERVICES.map((s) => ({ value: s, label: titleCase(s) }))]} />
68+ <ChipRow label="Orbit" paramKey="orbit" base={base} params={params} current={params.orbit} options={[{ value: undefined, label: 'All' }, ...ORBITS.map((o) => ({ value: o, label: o }))]} />
69+ </div>
70+
71+ <div className="py-6">
72+ {!res ? <Unavailable what="Constellation rankings" /> : <ConstellationTable rows={rows} offset={(page - 1) * (res.pagination.page_size || 100)} />}
73+ {res && (
74+ <Pagination
75+ className="mt-4"
76+ page={res.pagination.page}
77+ pages={res.pagination.pages}
78+ total={res.pagination.total}
79+ pageSize={res.pagination.page_size}
80+ makeHref={(p) => {
81+ const q = new URLSearchParams();
82+ for (const [k, v] of Object.entries(params)) if (v && k !== 'page') q.set(k, v);
83+ if (p > 1) q.set('page', String(p));
84+ const s = q.toString();
85+ return s ? `${base}?${s}` : base;
86+ }}
87+ />
88+ )}
89+ </div>
90+ </Container>
91+ );
92+}
93+
94+function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset: number }) {
95+ return (
96+ <ScrollTable>
97+ <table className="data-table stack">
98+ <thead>
99+ <tr>
100+ <th className="num">#</th>
101+ <th>Constellation</th>
102+ <th>Operator</th>
103+ <th>Service</th>
104+ <th>Orbit</th>
105+ <th className="num">Active</th>
106+ <th className="num">On orbit</th>
107+ <th className="num">Total</th>
108+ <th className="num">365 d</th>
109+ <th className="num">30 d</th>
110+ <th className="num">
111+ <span className="inline-flex items-center gap-1">Activity <Derived /></span>
112+ </th>
113+ <th className="num">Median perigee</th>
114+ <th>First launch</th>
115+ <th>Last launch</th>
116+ </tr>
117+ </thead>
118+ <tbody>
119+ {rows.length === 0 && <EmptyRow colSpan={14}>No constellation matches these filters.</EmptyRow>}
120+ {rows.map((c, i) => (
121+ <tr key={c.id}>
122+ <td data-label="Rank" className="num mono text-xs text-ink-3">{offset + i + 1}</td>
123+ <td className="primary" data-label="Constellation">
124+ <Link href={routes.constellation(c.slug)} className="link font-medium">{c.name}</Link>
125+ {c.country_code && <span className="mono ml-2 text-xs text-ink-3">{c.country_code}</span>}
126+ </td>
127+ <td data-label="Operator" className="text-ink-2">{c.operator_slug ? <Link href={routes.operator(c.operator_slug)} className="link">{c.operator_name}</Link> : c.operator_name ?? '—'}</td>
128+ <td data-label="Service" className="text-ink-2">{titleCase(c.service_type)}</td>
129+ <td data-label="Orbit"><OrbitBadge orbitClass={c.orbit_class} /></td>
130+ <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="Total" className="num tnum">{fmtInt(c.total)}</td>
133+ <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="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>
138+ <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(c.last_launch)}</td>
139+ </tr>
140+ ))}
141+ </tbody>
142+ </table>
143+ </ScrollTable>
144+ );
145+}
added apps/web/src/app/error.tsx +23 −0
@@ -0,0 +1,23 @@
1+'use client';
2+import Link from 'next/link';
3+import { useEffect } from 'react';
4+import { routes } from '@/lib/site';
5+
6+/** Route-level error boundary: never shows stack traces; offers retry and degrades to search/detail pages. */
7+export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
8+ useEffect(() => {
9+ console.error('route error', error.digest ?? error.message);
10+ }, [error]);
11+ return (
12+ <div className="container-x mx-auto flex max-w-[1600px] flex-col items-start justify-center py-24 md:py-40">
13+ <p className="eyebrow text-warn">Telemetry interrupted</p>
14+ <h1 className="display mt-3 text-4xl md:text-6xl">Something went wrong.</h1>
15+ <p className="mt-4 max-w-md text-ink-2">The data service did not respond in time. Cached pages and search keep working; this view can be retried.</p>
16+ <div className="mt-8 flex gap-3">
17+ <button type="button" onClick={reset} className="rounded-md bg-accent px-4 py-2.5 text-sm font-medium text-accent-ink">Retry</button>
18+ <Link href={routes.home()} className="rounded-md border border-rule-strong px-4 py-2.5 text-sm text-ink hover:bg-plane-2">Home</Link>
19+ </div>
20+ {error.digest && <p className="mono mt-6 text-xs text-ink-3">ref {error.digest}</p>}
21+ </div>
22+ );
23+}
added apps/web/src/app/globals.css +296 −0
@@ -0,0 +1,296 @@
1+@import 'tailwindcss';
2+
3+/*
4+ SatelliteIndex design tokens — restrained dark space interface.
5+ Near-black navy planes, off-white ink, electric cyan accent used sparingly, violet as the secondary accent.
6+ Status: green = active, orange = degraded/uncertain, red = failed/warning, gray = inactive.
7+ Orbit classes get fixed hues shared by the globe, charts and badges (LEO cyan · MEO violet · GEO amber · HEO pink · other gray).
8+*/
9+
10+:root {
11+ color-scheme: dark;
12+ --space: #060912; /* page plane */
13+ --plane: #0b1020; /* panels, header */
14+ --plane-2: #111830; /* hover wash, chips, inputs */
15+ --plane-3: #182140; /* active chip, table hover */
16+ --ink: #eaf0ff;
17+ --ink-2: #a3aec8;
18+ --ink-3: #6b7694;
19+ --rule: rgba(160, 180, 230, 0.12);
20+ --rule-strong: rgba(160, 180, 230, 0.28);
21+ --accent: #38d3ff;
22+ --accent-ink: #04111c;
23+ --accent-soft: rgba(56, 211, 255, 0.12);
24+ --accent-2: #8f7dff;
25+ --accent-2-soft: rgba(143, 125, 255, 0.14);
26+ --active: #38d17f;
27+ --active-soft: rgba(56, 209, 127, 0.14);
28+ --warn: #f5a524;
29+ --warn-soft: rgba(245, 165, 36, 0.14);
30+ --danger: #ff5c6c;
31+ --danger-soft: rgba(255, 92, 108, 0.14);
32+ --inactive: #7c869e;
33+ --inactive-soft: rgba(124, 134, 158, 0.16);
34+
35+ --leo: #38d3ff;
36+ --meo: #8f7dff;
37+ --geo: #f5b544;
38+ --heo: #ff7ab6;
39+ --other: #7c869e;
40+
41+ --series-1: #38d3ff;
42+ --series-2: #8f7dff;
43+ --series-3: #38d17f;
44+ --series-4: #f5b544;
45+ --series-5: #ff7ab6;
46+ --series-6: #5ea0ff;
47+ --series-7: #c9d3f2;
48+ --series-8: #ff5c6c;
49+
50+ --radius: 6px;
51+ --radius-lg: 12px;
52+ --header-h: 60px;
53+ --tabbar-h: 58px;
54+}
55+
56+@theme inline {
57+ --color-space: var(--space);
58+ --color-plane: var(--plane);
59+ --color-plane-2: var(--plane-2);
60+ --color-plane-3: var(--plane-3);
61+ --color-ink: var(--ink);
62+ --color-ink-2: var(--ink-2);
63+ --color-ink-3: var(--ink-3);
64+ --color-rule: var(--rule);
65+ --color-rule-strong: var(--rule-strong);
66+ --color-accent: var(--accent);
67+ --color-accent-ink: var(--accent-ink);
68+ --color-accent-soft: var(--accent-soft);
69+ --color-accent-2: var(--accent-2);
70+ --color-accent-2-soft: var(--accent-2-soft);
71+ --color-active: var(--active);
72+ --color-active-soft: var(--active-soft);
73+ --color-warn: var(--warn);
74+ --color-warn-soft: var(--warn-soft);
75+ --color-danger: var(--danger);
76+ --color-danger-soft: var(--danger-soft);
77+ --color-inactive: var(--inactive);
78+ --color-inactive-soft: var(--inactive-soft);
79+ --color-leo: var(--leo);
80+ --color-meo: var(--meo);
81+ --color-geo: var(--geo);
82+ --color-heo: var(--heo);
83+ --color-other: var(--other);
84+ --color-series-1: var(--series-1);
85+ --color-series-2: var(--series-2);
86+ --color-series-3: var(--series-3);
87+ --color-series-4: var(--series-4);
88+ --color-series-5: var(--series-5);
89+ --color-series-6: var(--series-6);
90+ --color-series-7: var(--series-7);
91+ --color-series-8: var(--series-8);
92+
93+ --font-sans: var(--font-ui), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
94+ --font-mono: var(--font-mono), ui-monospace, 'SF Mono', Menlo, monospace;
95+
96+ --text-2xs: 0.6875rem;
97+ --text-2xs--line-height: 1rem;
98+ --radius-sm: var(--radius);
99+ --radius-lg: var(--radius-lg);
100+}
101+
102+html {
103+ background: var(--space);
104+ color: var(--ink);
105+ font-family: var(--font-sans);
106+ -webkit-text-size-adjust: 100%;
107+ scroll-behavior: auto;
108+}
109+
110+body {
111+ background:
112+ radial-gradient(1200px 600px at 70% -10%, rgba(56, 211, 255, 0.07), transparent 60%),
113+ radial-gradient(900px 500px at 0% 20%, rgba(143, 125, 255, 0.06), transparent 60%),
114+ var(--space);
115+ min-height: 100%;
116+ overflow-x: hidden;
117+}
118+
119+::selection {
120+ background: rgba(56, 211, 255, 0.35);
121+}
122+
123+/* ---- utilities ---------------------------------------------------------------------------------------------- */
124+.container-x {
125+ padding-left: max(1rem, env(safe-area-inset-left));
126+ padding-right: max(1rem, env(safe-area-inset-right));
127+}
128+@media (min-width: 768px) {
129+ .container-x {
130+ padding-left: 2rem;
131+ padding-right: 2rem;
132+ }
133+}
134+.safe-bottom {
135+ padding-bottom: env(safe-area-inset-bottom, 0px);
136+}
137+.mono {
138+ font-family: var(--font-mono);
139+ font-variant-numeric: tabular-nums;
140+ letter-spacing: 0.01em;
141+}
142+.tnum {
143+ font-variant-numeric: tabular-nums;
144+}
145+.eyebrow {
146+ font-size: 0.6875rem;
147+ line-height: 1rem;
148+ letter-spacing: 0.14em;
149+ text-transform: uppercase;
150+ color: var(--ink-3);
151+ font-weight: 600;
152+}
153+.display {
154+ font-weight: 650;
155+ letter-spacing: -0.03em;
156+ line-height: 0.98;
157+}
158+.panel {
159+ background: color-mix(in oklab, var(--plane) 88%, transparent);
160+ border: 1px solid var(--rule);
161+ border-radius: var(--radius-lg);
162+ backdrop-filter: blur(10px);
163+}
164+.hairline {
165+ border-color: var(--rule);
166+}
167+.grid-bg {
168+ background-image:
169+ linear-gradient(to right, rgba(160, 180, 230, 0.05) 1px, transparent 1px),
170+ linear-gradient(to bottom, rgba(160, 180, 230, 0.05) 1px, transparent 1px);
171+ background-size: 48px 48px;
172+ mask-image: radial-gradient(ellipse at center, black 40%, transparent 85%);
173+}
174+.scrollbar-thin {
175+ scrollbar-width: thin;
176+ scrollbar-color: var(--rule-strong) transparent;
177+}
178+.no-scrollbar::-webkit-scrollbar {
179+ display: none;
180+}
181+.no-scrollbar {
182+ scrollbar-width: none;
183+}
184+.link {
185+ color: var(--ink);
186+ text-decoration-color: var(--rule-strong);
187+ text-underline-offset: 3px;
188+}
189+.link:hover {
190+ color: var(--accent);
191+ text-decoration: underline;
192+}
193+.focus-ring:focus-visible {
194+ outline: 2px solid var(--accent);
195+ outline-offset: 2px;
196+}
197+a:focus-visible,
198+button:focus-visible,
199+input:focus-visible,
200+select:focus-visible,
201+summary:focus-visible {
202+ outline: 2px solid var(--accent);
203+ outline-offset: 2px;
204+ border-radius: 4px;
205+}
206+
207+/* status dot */
208+.dot {
209+ display: inline-block;
210+ width: 7px;
211+ height: 7px;
212+ border-radius: 999px;
213+ background: currentColor;
214+ box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 20%, transparent);
215+}
216+.pulse {
217+ animation: pulse 2.4s ease-in-out infinite;
218+}
219+@keyframes pulse {
220+ 0%,
221+ 100% {
222+ box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 20%, transparent);
223+ }
224+ 50% {
225+ box-shadow: 0 0 0 7px color-mix(in oklab, currentColor 6%, transparent);
226+ }
227+}
228+
229+/* data tables: transform to stacked rows on small screens when marked */
230+.data-table {
231+ width: 100%;
232+ border-collapse: collapse;
233+ font-variant-numeric: tabular-nums;
234+}
235+.data-table th {
236+ text-align: left;
237+ font-weight: 600;
238+ font-size: 0.6875rem;
239+ letter-spacing: 0.1em;
240+ text-transform: uppercase;
241+ color: var(--ink-3);
242+ padding: 0.5rem 0.75rem;
243+ border-bottom: 1px solid var(--rule-strong);
244+ white-space: nowrap;
245+}
246+.data-table td {
247+ padding: 0.6rem 0.75rem;
248+ border-bottom: 1px solid var(--rule);
249+ vertical-align: middle;
250+}
251+.data-table tbody tr:hover {
252+ background: var(--plane-2);
253+}
254+.data-table .num {
255+ text-align: right;
256+}
257+@media (max-width: 767px) {
258+ .data-table.stack thead {
259+ display: none;
260+ }
261+ .data-table.stack tr {
262+ display: grid;
263+ grid-template-columns: 1fr 1fr;
264+ gap: 0.15rem 0.75rem;
265+ padding: 0.7rem 0;
266+ border-bottom: 1px solid var(--rule);
267+ }
268+ .data-table.stack td {
269+ display: block;
270+ border: 0;
271+ padding: 0;
272+ }
273+ .data-table.stack td[data-label]::before {
274+ content: attr(data-label);
275+ display: block;
276+ font-size: 0.625rem;
277+ letter-spacing: 0.1em;
278+ text-transform: uppercase;
279+ color: var(--ink-3);
280+ }
281+ .data-table.stack td.primary {
282+ grid-column: 1 / -1;
283+ }
284+ .data-table.stack td.num {
285+ text-align: left;
286+ }
287+}
288+
289+@media (prefers-reduced-motion: reduce) {
290+ *,
291+ *::before,
292+ *::after {
293+ animation-duration: 0.01ms !important;
294+ transition-duration: 0.01ms !important;
295+ }
296+}
added apps/web/src/app/layout.tsx +49 −0
@@ -0,0 +1,49 @@
1+import type { Metadata, Viewport } from 'next';
2+import './globals.css';
3+import { MobileTabBar } from '@/components/layout/mobile-tab-bar';
4+import { SearchProvider } from '@/components/layout/search-context';
5+import { SearchDialog } from '@/components/layout/search-dialog';
6+import { SiteFooter } from '@/components/layout/site-footer';
7+import { SiteHeader } from '@/components/layout/site-header';
8+import { fontMono, fontUi } from '@/lib/fonts';
9+import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';
10+
11+export const metadata: Metadata = {
12+ metadataBase: new URL(SITE_URL),
13+ title: { default: `${SITE_NAME} — ${TAGLINE}`, template: `%s | ${SITE_NAME}` },
14+ description: DESCRIPTION,
15+ applicationName: SITE_NAME,
16+ robots: { index: true, follow: true },
17+ alternates: { canonical: '/' },
18+ openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
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' }] },
21+};
22+
23+export const viewport: Viewport = {
24+ width: 'device-width',
25+ initialScale: 1,
26+ viewportFit: 'cover',
27+ themeColor: '#060912',
28+};
29+
30+export default function RootLayout({ children }: { children: React.ReactNode }) {
31+ return (
32+ <html lang="en" className={`${fontUi.variable} ${fontMono.variable} h-full antialiased`}>
33+ <body className="flex min-h-full flex-col pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px))] md:pb-0">
34+ <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-sm focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-accent-ink">
35+ Skip to content
36+ </a>
37+ <SearchProvider>
38+ <SiteHeader />
39+ <main id="main" className="flex-1">
40+ {children}
41+ </main>
42+ <SiteFooter />
43+ <MobileTabBar />
44+ <SearchDialog />
45+ </SearchProvider>
46+ </body>
47+ </html>
48+ );
49+}
added apps/web/src/app/not-found.tsx +16 −0
@@ -0,0 +1,16 @@
1+import Link from 'next/link';
2+import { routes } from '@/lib/site';
3+
4+export default function NotFound() {
5+ return (
6+ <div className="container-x mx-auto flex max-w-[1600px] flex-col items-start justify-center py-24 md:py-40">
7+ <p className="eyebrow">404 · Object not catalogued</p>
8+ <h1 className="display mt-3 text-4xl md:text-6xl">This page is not in orbit.</h1>
9+ <p className="mt-4 max-w-md text-ink-2">The page you asked for does not exist, or the object was renamed. Try a search by name, NORAD or COSPAR id.</p>
10+ <div className="mt-8 flex gap-3">
11+ <Link href={routes.home()} className="rounded-md bg-accent px-4 py-2.5 text-sm font-medium text-accent-ink">Back to the index</Link>
12+ <Link href={routes.search()} className="rounded-md border border-rule-strong px-4 py-2.5 text-sm text-ink hover:bg-plane-2">Search</Link>
13+ </div>
14+ </div>
15+ );
16+}
added apps/web/src/app/page.tsx +10 −0
@@ -0,0 +1,10 @@
1+import { Container, PageHeader } from '@/components/ui/section';
2+
3+/** Placeholder — replaced by the real homepage (components/home). */
4+export default function HomePlaceholder() {
5+ return (
6+ <Container>
7+ <PageHeader eyebrow="SatelliteIndex" title="Building…" />
8+ </Container>
9+ );
10+}
added apps/web/src/components/brand/logo.tsx +25 −0
@@ -0,0 +1,25 @@
1+import { cn } from '@/lib/cn';
2+
3+/** SatelliteIndex mark: a planet disc with an inclined orbital ring and a satellite node. Single SVG, currentColor. */
4+export function LogoMark({ size = 28, className }: { size?: number; className?: string }) {
5+ return (
6+ <svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden className={cn('shrink-0', className)}>
7+ <circle cx="16" cy="16" r="7.5" fill="currentColor" opacity="0.92" />
8+ <ellipse cx="16" cy="16" rx="14" ry="5.2" stroke="currentColor" strokeWidth="1.6" transform="rotate(-24 16 16)" opacity="0.85" />
9+ <circle cx="27.4" cy="9.6" r="2.3" fill="var(--accent)" />
10+ </svg>
11+ );
12+}
13+
14+export function Wordmark({ className, compact = false }: { className?: string; compact?: boolean }) {
15+ return (
16+ <span className={cn('inline-flex items-center gap-2 font-semibold tracking-tight', className)}>
17+ <LogoMark size={26} className="text-ink" />
18+ {!compact && (
19+ <span className="text-[15px] leading-none">
20+ Satellite<span className="text-accent">Index</span>
21+ </span>
22+ )}
23+ </span>
24+ );
25+}
added apps/web/src/components/charts/charts.tsx +253 −0
@@ -0,0 +1,253 @@
1+/**
2+ * Small, dependency-light SVG charts (server-component friendly, no client JS). One visual system:
3+ * hairline axes, tabular numbers, series colours from the design tokens, dark surfaces.
4+ * Every chart accepts `title` (visually hidden <title>) for accessibility and renders an "Unavailable" state for empty data.
5+ */
6+import { max as d3max } from 'd3-array';
7+import { scaleBand, scaleLinear } from 'd3-scale';
8+import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape';
9+import { cn } from '@/lib/cn';
10+import { fmtCompact, fmtInt } from '@/lib/format';
11+
12+export const SERIES = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)', 'var(--series-4)', 'var(--series-5)', 'var(--series-6)', 'var(--series-7)', 'var(--series-8)'];
13+
14+function Empty({ className, h }: { className?: string; h: number }) {
15+ return (
16+ <div className={cn('flex items-center justify-center rounded-md border border-dashed border-rule text-xs text-ink-3', className)} style={{ height: h }} role="img" aria-label="Chart unavailable">
17+ Unavailable
18+ </div>
19+ );
20+}
21+
22+export interface BarDatum {
23+ label: string;
24+ value: number;
25+ color?: string;
26+ href?: string;
27+}
28+
29+/** Horizontal bars with labels — ranking lists. */
30+export function HBars({ data, className, max, valueFormat = fmtInt, barHeight = 26, showValue = true }: { data: BarDatum[]; className?: string; max?: number; valueFormat?: (v: number) => string; barHeight?: number; showValue?: boolean }) {
31+ if (!data.length) return <Empty className={className} h={120} />;
32+ const m = max ?? d3max(data, (d) => d.value) ?? 1;
33+ return (
34+ <ul className={cn('space-y-1.5', className)}>
35+ {data.map((d, i) => (
36+ <li key={`${d.label}-${i}`} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm" style={{ minHeight: barHeight }}>
37+ <div className="min-w-0">
38+ <div className="flex items-baseline justify-between gap-2">
39+ <span className="truncate text-ink">{d.label}</span>
40+ {showValue && <span className="tnum shrink-0 text-xs text-ink-2">{valueFormat(d.value)}</span>}
41+ </div>
42+ <div className="mt-1 h-[6px] w-full overflow-hidden rounded-full bg-plane-2">
43+ <div className="h-full rounded-full" style={{ width: `${Math.max(1, (d.value / m) * 100)}%`, background: d.color ?? 'var(--series-1)' }} />
44+ </div>
45+ </div>
46+ </li>
47+ ))}
48+ </ul>
49+ );
50+}
51+
52+export interface SeriesPoint {
53+ x: number | string;
54+ y: number;
55+}
56+
57+/** Vertical bar chart (time series by year/month). */
58+export function Bars({ data, className, height = 160, color = 'var(--series-1)', title, xTicks = 6, yFormat = fmtCompact, highlightLast = false }: { data: SeriesPoint[]; className?: string; height?: number; color?: string; title: string; xTicks?: number; yFormat?: (v: number) => string; highlightLast?: boolean }) {
59+ if (!data.length) return <Empty className={className} h={height} />;
60+ const W = 640;
61+ const H = height;
62+ const pad = { l: 36, r: 8, t: 8, b: 22 };
63+ const x = scaleBand<string>().domain(data.map((d) => String(d.x))).range([pad.l, W - pad.r]).paddingInner(0.25);
64+ const ymax = d3max(data, (d) => d.y) ?? 1;
65+ const y = scaleLinear().domain([0, ymax || 1]).nice().range([H - pad.b, pad.t]);
66+ const ticks = y.ticks(4);
67+ const every = Math.max(1, Math.ceil(data.length / xTicks));
68+ return (
69+ <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none">
70+ <title>{title}</title>
71+ {ticks.map((t) => (
72+ <g key={t}>
73+ <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
74+ <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{yFormat(t)}</text>
75+ </g>
76+ ))}
77+ {data.map((d, i) => (
78+ <rect key={String(d.x)} x={x(String(d.x))} y={y(d.y)} width={x.bandwidth()} height={Math.max(0, H - pad.b - y(d.y))} fill={highlightLast && i === data.length - 1 ? 'var(--accent)' : color} opacity={0.9} rx={1} />
79+ ))}
80+ {data.map((d, i) => (i % every === 0 || i === data.length - 1) && (
81+ <text key={`t${String(d.x)}`} x={(x(String(d.x)) ?? 0) + x.bandwidth() / 2} y={H - 6} textAnchor="middle" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{String(d.x).slice(0, 7)}</text>
82+ ))}
83+ </svg>
84+ );
85+}
86+
87+/** Stacked vertical bars (e.g. launches by year by region). `keys` order = stack order; colours from SERIES. */
88+export function StackedBars({ data, keys, labels, className, height = 200, title, xTicks = 8 }: { data: Record<string, number | string>[]; keys: string[]; labels?: Record<string, string>; className?: string; height?: number; title: string; xTicks?: number }) {
89+ if (!data.length) return <Empty className={className} h={height} />;
90+ const W = 640;
91+ const H = height;
92+ const pad = { l: 36, r: 8, t: 8, b: 22 };
93+ const xs = data.map((d) => String(d.x));
94+ const x = scaleBand<string>().domain(xs).range([pad.l, W - pad.r]).paddingInner(0.2);
95+ const totals = data.map((d) => keys.reduce((s, k) => s + (Number(d[k]) || 0), 0));
96+ const y = scaleLinear().domain([0, d3max(totals) || 1]).nice().range([H - pad.b, pad.t]);
97+ const every = Math.max(1, Math.ceil(data.length / xTicks));
98+ return (
99+ <div className={className}>
100+ <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label={title} preserveAspectRatio="none">
101+ <title>{title}</title>
102+ {y.ticks(4).map((t) => (
103+ <g key={t}>
104+ <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
105+ <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtCompact(t)}</text>
106+ </g>
107+ ))}
108+ {data.map((d, i) => {
109+ let acc = 0;
110+ return keys.map((k, ki) => {
111+ const v = Number(d[k]) || 0;
112+ const y0 = y(acc);
113+ acc += v;
114+ const y1 = y(acc);
115+ return <rect key={`${i}-${k}`} x={x(String(d.x))} y={y1} width={x.bandwidth()} height={Math.max(0, y0 - y1)} fill={SERIES[ki % SERIES.length]} opacity={0.9} />;
116+ });
117+ })}
118+ {xs.map((lab, i) => (i % every === 0 || i === xs.length - 1) && (
119+ <text key={lab} x={(x(lab) ?? 0) + x.bandwidth() / 2} y={H - 6} textAnchor="middle" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{lab.slice(0, 7)}</text>
120+ ))}
121+ </svg>
122+ <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">
123+ {keys.map((k, i) => (
124+ <li key={k} className="inline-flex items-center gap-1.5">
125+ <span className="inline-block size-2.5 rounded-sm" style={{ background: SERIES[i % SERIES.length] }} /> {labels?.[k] ?? k}
126+ </li>
127+ ))}
128+ </ul>
129+ </div>
130+ );
131+}
132+
133+/** Area/line chart for continuous series. */
134+export function AreaChart({ data, className, height = 160, color = 'var(--series-1)', title, yFormat = fmtCompact, xLabel, fill = true, yDomain }: { data: SeriesPoint[]; className?: string; height?: number; color?: string; title: string; yFormat?: (v: number) => string; xLabel?: (x: number | string) => string; fill?: boolean; yDomain?: [number, number] }) {
135+ if (data.length < 2) return <Empty className={className} h={height} />;
136+ const W = 640;
137+ const H = height;
138+ const pad = { l: 40, r: 8, t: 8, b: 22 };
139+ const x = scaleLinear().domain([0, data.length - 1]).range([pad.l, W - pad.r]);
140+ const ys = data.map((d) => d.y);
141+ const lo = yDomain ? yDomain[0] : Math.min(...ys);
142+ const hi = yDomain ? yDomain[1] : Math.max(...ys);
143+ const padY = (hi - lo) * 0.1 || 1;
144+ const y = scaleLinear().domain([yDomain ? lo : lo - padY, yDomain ? hi : hi + padY]).nice().range([H - pad.b, pad.t]);
145+ const ln = d3line<SeriesPoint>().x((_, i) => x(i)).y((d) => y(d.y)).curve(curveMonotoneX);
146+ const ar = d3area<SeriesPoint>().x((_, i) => x(i)).y0(H - pad.b).y1((d) => y(d.y)).curve(curveMonotoneX);
147+ const every = Math.max(1, Math.ceil(data.length / 6));
148+ const gid = `g${Math.abs(hashStr(title))}`;
149+ return (
150+ <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none">
151+ <title>{title}</title>
152+ <defs>
153+ <linearGradient id={gid} x1="0" x2="0" y1="0" y2="1">
154+ <stop offset="0" stopColor={color} stopOpacity="0.35" />
155+ <stop offset="1" stopColor={color} stopOpacity="0" />
156+ </linearGradient>
157+ </defs>
158+ {y.ticks(4).map((t) => (
159+ <g key={t}>
160+ <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
161+ <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{yFormat(t)}</text>
162+ </g>
163+ ))}
164+ {fill && <path d={ar(data) ?? ''} fill={`url(#${gid})`} />}
165+ <path d={ln(data) ?? ''} fill="none" stroke={color} strokeWidth={1.8} />
166+ {data.map((d, i) => (i % every === 0 || i === data.length - 1) && (
167+ <text key={i} x={x(i)} y={H - 6} textAnchor={i === data.length - 1 ? 'end' : i === 0 ? 'start' : 'middle'} fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{xLabel ? xLabel(d.x) : String(d.x).slice(0, 7)}</text>
168+ ))}
169+ </svg>
170+ );
171+}
172+
173+/** Donut with legend — status/orbit distributions. */
174+export function Donut({ data, className, size = 140, title, total }: { data: BarDatum[]; className?: string; size?: number; title: string; total?: number }) {
175+ const sum = data.reduce((s, d) => s + d.value, 0);
176+ if (!sum) return <Empty className={className} h={size} />;
177+ const r = size / 2;
178+ const stroke = size * 0.16;
179+ const c = 2 * Math.PI * (r - stroke / 2);
180+ let acc = 0;
181+ return (
182+ <div className={cn('flex items-center gap-5', className)}>
183+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={title} className="shrink-0">
184+ <title>{title}</title>
185+ <circle cx={r} cy={r} r={r - stroke / 2} fill="none" stroke="var(--plane-2)" strokeWidth={stroke} />
186+ {data.map((d, i) => {
187+ const frac = d.value / sum;
188+ const el = <circle key={i} cx={r} cy={r} r={r - stroke / 2} fill="none" stroke={d.color ?? SERIES[i % SERIES.length]} strokeWidth={stroke} strokeDasharray={`${frac * c} ${c}`} strokeDashoffset={-acc * c} transform={`rotate(-90 ${r} ${r})`} />;
189+ acc += frac;
190+ return el;
191+ })}
192+ <text x={r} y={r} dy="0.35em" textAnchor="middle" fontSize={size * 0.16} fontWeight={600} fill="var(--ink)" fontFamily="var(--font-mono)">{fmtCompact(total ?? sum)}</text>
193+ </svg>
194+ <ul className="min-w-0 flex-1 space-y-1 text-sm">
195+ {data.map((d, i) => (
196+ <li key={i} className="flex items-center justify-between gap-3">
197+ <span className="inline-flex min-w-0 items-center gap-2 text-ink-2"><span className="inline-block size-2.5 shrink-0 rounded-sm" style={{ background: d.color ?? SERIES[i % SERIES.length] }} /><span className="truncate">{d.label}</span></span>
198+ <span className="tnum shrink-0 text-ink">{fmtInt(d.value)} <span className="text-ink-3">· {((d.value / sum) * 100).toFixed(0)}%</span></span>
199+ </li>
200+ ))}
201+ </ul>
202+ </div>
203+ );
204+}
205+
206+/** Tiny inline sparkline. */
207+export function Sparkline({ values, width = 90, height = 24, color = 'var(--accent)' }: { values: number[]; width?: number; height?: number; color?: string }) {
208+ if (values.length < 2) return <span className="text-xs text-ink-3">—</span>;
209+ const x = scaleLinear().domain([0, values.length - 1]).range([1, width - 1]);
210+ const y = scaleLinear().domain([Math.min(...values), Math.max(...values) || 1]).range([height - 2, 2]);
211+ const d = d3line<number>().x((_, i) => x(i)).y((v) => y(v)).curve(curveMonotoneX)(values) ?? '';
212+ return (
213+ <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="inline-block align-middle">
214+ <path d={d} fill="none" stroke={color} strokeWidth={1.5} />
215+ </svg>
216+ );
217+}
218+
219+/** Histogram (altitude/inclination profiles): values already bucketed. */
220+export function Histogram({ data, className, height = 140, color = 'var(--series-1)', title, unit = '', xTicks = 8 }: { data: { bin: number; value: number }[]; className?: string; height?: number; color?: string; title: string; unit?: string; xTicks?: number }) {
221+ if (!data.length) return <Empty className={className} h={height} />;
222+ const W = 640;
223+ const H = height;
224+ const pad = { l: 36, r: 8, t: 6, b: 22 };
225+ const bins = data.map((d) => d.bin);
226+ const step = bins.length > 1 ? Math.min(...bins.slice(1).map((b, i) => b - (bins[i] ?? 0)).filter((v) => v > 0)) || 1 : 1;
227+ const x = scaleLinear().domain([Math.min(...bins), Math.max(...bins) + step]).range([pad.l, W - pad.r]);
228+ const y = scaleLinear().domain([0, d3max(data, (d) => d.value) || 1]).nice().range([H - pad.b, pad.t]);
229+ const bw = Math.max(1, x(step) - x(0) - 1);
230+ return (
231+ <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none">
232+ <title>{title}</title>
233+ {y.ticks(3).map((t) => (
234+ <g key={t}>
235+ <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
236+ <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtCompact(t)}</text>
237+ </g>
238+ ))}
239+ {data.map((d) => (
240+ <rect key={d.bin} x={x(d.bin)} y={y(d.value)} width={bw} height={Math.max(0, H - pad.b - y(d.value))} fill={color} opacity={0.9} />
241+ ))}
242+ {x.ticks(xTicks).map((t) => (
243+ <text key={t} x={x(t)} y={H - 6} textAnchor="middle" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtCompact(t)}{unit}</text>
244+ ))}
245+ </svg>
246+ );
247+}
248+
249+function hashStr(s: string): number {
250+ let h = 0;
251+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
252+ return h;
253+}
added apps/web/src/components/entities/constellation-panels.tsx +129 −0
@@ -0,0 +1,129 @@
1+/** Visualisation panels for /constellation/[slug]. Server components, pure SVG charts. */
2+import Link from 'next/link';
3+import { AreaChart, Bars, Donut, Histogram } from '@/components/charts/charts';
4+import { Unavailable } from '@/components/ui/unavailable';
5+import { fmtDeg, fmtInt, fmtKm, num, titleCase } from '@/lib/format';
6+import { STATUS_COLORS, routes } from '@/lib/site';
7+import type { ConstellationDetail } from '@/lib/types';
8+import { Derived, ScrollTable } from './shared';
9+
10+export function DeploymentTimeline({ growth }: { growth: ConstellationDetail['growth'] }) {
11+ const monthly = growth.map((g) => ({ x: g.month.slice(0, 7), y: num(g.launched) ?? 0 }));
12+ const cumulative = growth.map((g) => ({ x: g.month.slice(0, 7), y: num(g.cumulative) ?? 0 }));
13+ if (!growth.length) return <Unavailable what="Deployment timeline" />;
14+ return (
15+ <div className="grid gap-6 lg:grid-cols-2">
16+ <div>
17+ <p className="eyebrow mb-2">Cumulative satellites launched</p>
18+ <AreaChart data={cumulative} title="Cumulative satellites launched by month" height={180} xLabel={(x) => String(x).slice(0, 4)} />
19+ </div>
20+ <div>
21+ <p className="eyebrow mb-2">Launched per month</p>
22+ <Bars data={monthly} title="Satellites launched per month" height={180} color="var(--series-2)" highlightLast />
23+ </div>
24+ </div>
25+ );
26+}
27+
28+export function StatusDonut({ dist, total }: { dist: ConstellationDetail['status_distribution']; total: number | null }) {
29+ const data = dist.map((d) => ({ label: titleCase(d.status.toLowerCase()), value: num(d.count) ?? 0, color: STATUS_COLORS[d.status] ?? 'var(--other)' })).filter((d) => d.value > 0).sort((a, b) => b.value - a.value);
30+ return <Donut data={data} title="Satellites by status" total={total ?? undefined} size={150} />;
31+}
32+
33+export function OrbitalShells({ d }: { d: ConstellationDetail }) {
34+ const alt = d.altitude_histogram.map((h) => ({ bin: num(h.alt_km) ?? 0, value: num(h.satellites) ?? 0 })).filter((h) => h.bin >= 0);
35+ const incl = d.inclination_histogram.map((h) => ({ bin: num(h.incl_deg) ?? 0, value: num(h.satellites) ?? 0 }));
36+ const shells = [...d.shells].sort((a, b) => (num(b.satellites) ?? 0) - (num(a.satellites) ?? 0)).slice(0, 10);
37+ const nothing = !alt.length && !incl.length && !shells.length;
38+ if (nothing) return <Unavailable what="Orbital shell data (no current orbital elements for this constellation)" />;
39+ return (
40+ <div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
41+ <div className="space-y-6">
42+ <div>
43+ <p className="eyebrow mb-2">Perigee altitude · satellites per 25 km</p>
44+ <Histogram data={alt} title="Perigee altitude histogram" unit=" km" height={150} xTicks={6} />
45+ </div>
46+ <div>
47+ <p className="eyebrow mb-2">Inclination · satellites per 5°</p>
48+ <Histogram data={incl} title="Inclination histogram" unit="°" height={130} color="var(--series-2)" xTicks={6} />
49+ </div>
50+ </div>
51+ <div>
52+ <p className="eyebrow mb-2">Largest shells (perigee × inclination)</p>
53+ {shells.length === 0 ? (
54+ <Unavailable what="Shell table" compact />
55+ ) : (
56+ <ScrollTable>
57+ <table className="data-table">
58+ <thead>
59+ <tr>
60+ <th className="num">Perigee</th>
61+ <th className="num">Inclination</th>
62+ <th className="num">Satellites</th>
63+ </tr>
64+ </thead>
65+ <tbody>
66+ {shells.map((s, i) => (
67+ <tr key={i}>
68+ <td className="num tnum">{fmtKm(s.perigee_km)}</td>
69+ <td className="num tnum">{fmtDeg(s.inclination_deg)}</td>
70+ <td className="num tnum font-medium">{fmtInt(s.satellites)}</td>
71+ </tr>
72+ ))}
73+ </tbody>
74+ </table>
75+ </ScrollTable>
76+ )}
77+ <p className="mt-2 text-xs text-ink-3">Shells bucket current elements to 20 km × 1°. Orbit classes are <Link href={routes.methodology()} className="text-accent hover:underline">derived</Link>.</p>
78+ </div>
79+ </div>
80+ );
81+}
82+
83+export function DecaysByMonth({ rows }: { rows: ConstellationDetail['decays_by_month'] }) {
84+ const data = rows.map((r) => ({ x: r.month.slice(0, 7), y: num(r.decayed) ?? 0 }));
85+ const total = data.reduce((s, d) => s + d.y, 0);
86+ if (!data.length) return null;
87+ return (
88+ <div>
89+ <p className="eyebrow mb-2">{fmtInt(total)} decays recorded · per month</p>
90+ <Bars data={data} title="Decayed satellites per month" height={150} color="var(--series-8)" />
91+ </div>
92+ );
93+}
94+
95+export function MembershipNote({ d }: { d: ConstellationDetail }) {
96+ const methods = d.membership_methods ?? [];
97+ return (
98+ <div className="space-y-3 text-sm text-ink-2">
99+ <p className="flex flex-wrap items-center gap-2">
100+ <span>Constellation membership is</span> <Derived /> <span>from curated rules, never from a single upstream field. Rules for this constellation:</span>
101+ </p>
102+ <dl className="grid gap-3 sm:grid-cols-2">
103+ <div>
104+ <dt className="eyebrow">Name patterns</dt>
105+ <dd className="mt-1 flex flex-wrap gap-1.5">{d.match_patterns.length ? d.match_patterns.map((p) => <code key={p} className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{p}</code>) : <span className="text-ink-3">none</span>}</dd>
106+ </div>
107+ <div>
108+ <dt className="eyebrow">CelesTrak groups</dt>
109+ <dd className="mt-1 flex flex-wrap gap-1.5">{d.celestrak_groups.length ? d.celestrak_groups.map((g) => <code key={g} className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{g}</code>) : <span className="text-ink-3">none</span>}</dd>
110+ </div>
111+ </dl>
112+ <div>
113+ <p className="eyebrow">Members by method</p>
114+ {methods.length ? (
115+ <ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
116+ {methods.map((m) => (
117+ <li key={m.method} className="tnum"><code className="mono text-xs text-ink">{m.method}</code> · {fmtInt(m.satellites)}</li>
118+ ))}
119+ </ul>
120+ ) : (
121+ <p className="mt-1 text-xs text-ink-3">Per-method breakdown unavailable for this constellation.</p>
122+ )}
123+ </div>
124+ <p className="text-xs text-ink-3">
125+ Full rule set and metric versions on the <Link href={routes.methodology()} className="text-accent hover:underline">methodology page</Link>. Activity score is a derived index of recent launch cadence, not a safety metric.
126+ </p>
127+ </div>
128+ );
129+}
added apps/web/src/components/entities/shared.tsx +214 −0
@@ -0,0 +1,214 @@
1+/**
2+ * Shared building blocks for entity pages (constellations, operators, countries). Server components only.
3+ * Kept local to `components/entities/` so shared UI stays untouched.
4+ */
5+import { ExternalLink as ExternalIcon } from 'lucide-react';
6+import Link from 'next/link';
7+import type { ReactNode } from 'react';
8+import { cn } from '@/lib/cn';
9+import { fmtDateTime, fmtInt, titleCase } from '@/lib/format';
10+import { EVENT_TYPE_LABELS, SITE_NAME, SITE_URL, routes } from '@/lib/site';
11+import type { EventRow } from '@/lib/types';
12+import { Unavailable } from '@/components/ui/unavailable';
13+
14+// ------------------------------------------------------------------------------------------------------- metadata
15+export function entityMetadata({ title, description, path, ogImage }: { title: string; description: string; path: string; ogImage?: string }) {
16+ const url = `${SITE_URL}${path}`;
17+ const images = ogImage ? [{ url: ogImage, width: 1200, height: 630 }] : undefined;
18+ return {
19+ title,
20+ description,
21+ alternates: { canonical: path },
22+ openGraph: { type: 'website' as const, siteName: SITE_NAME, url, title: `${title} | ${SITE_NAME}`, description, images },
23+ twitter: { card: 'summary_large_image' as const, title: `${title} | ${SITE_NAME}`, description, images: images?.map((i) => i.url) },
24+ };
25+}
26+
27+// --------------------------------------------------------------------------------------------------- URL helpers
28+export type Params = Record<string, string | undefined>;
29+
30+/** Build an href for the current list page with one param changed (drops `page`). */
31+export function withParam(base: string, current: Params, key: string, value: string | undefined): string {
32+ const p = new URLSearchParams();
33+ for (const [k, v] of Object.entries(current)) if (v && k !== 'page' && k !== key) p.set(k, v);
34+ if (value) p.set(key, value);
35+ const s = p.toString();
36+ return s ? `${base}?${s}` : base;
37+}
38+
39+export function first(v: string | string[] | undefined): string | undefined {
40+ return Array.isArray(v) ? v[0] : v;
41+}
42+
43+// -------------------------------------------------------------------------------------------------------- chips
44+export interface ChipOption {
45+ value: string | undefined;
46+ label: string;
47+ count?: number | null;
48+}
49+
50+/** Row of link chips (sort / filter). ≥ 44 px tap targets, horizontally scrollable on small screens. */
51+export function ChipRow({ label, options, current, base, params, paramKey, className }: { label: string; options: ChipOption[]; current: string | undefined; base: string; params: Params; paramKey: string; className?: string }) {
52+ return (
53+ <div className={cn('flex min-w-0 items-center gap-2', className)}>
54+ <span className="eyebrow shrink-0">{label}</span>
55+ <ul className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto py-1" role="list">
56+ {options.map((o) => {
57+ const active = (o.value ?? '') === (current ?? '');
58+ return (
59+ <li key={o.label} className="shrink-0">
60+ <Link
61+ href={withParam(base, params, paramKey, o.value)}
62+ aria-current={active ? 'true' : undefined}
63+ className={cn('inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-[13px] transition-colors', active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2 hover:text-ink')}
64+ >
65+ {o.label}
66+ {o.count != null && <span className="tnum text-[11px] text-ink-3">{fmtInt(o.count)}</span>}
67+ </Link>
68+ </li>
69+ );
70+ })}
71+ </ul>
72+ </div>
73+ );
74+}
75+
76+// ---------------------------------------------------------------------------------------------------- KPI strip
77+export interface Kpi {
78+ label: string;
79+ value: ReactNode;
80+ hint?: ReactNode;
81+ derived?: boolean;
82+}
83+
84+/** Hairline-separated strip of big numbers — not a wall of cards. */
85+export function KpiStrip({ items, className }: { items: Kpi[]; className?: string }) {
86+ return (
87+ <dl className={cn('grid grid-cols-2 gap-x-6 gap-y-5 border-y border-rule py-5 sm:grid-cols-3 lg:grid-cols-6', className)}>
88+ {items.map((k) => (
89+ <div key={k.label} className="min-w-0">
90+ <dt className="eyebrow flex flex-wrap items-center gap-1.5">
91+ {k.label}
92+ {k.derived && <Derived />}
93+ </dt>
94+ <dd className="tnum mt-1 text-2xl font-semibold tracking-tight md:text-[28px]">{k.value}</dd>
95+ {k.hint && <dd className="mt-0.5 text-xs text-ink-3">{k.hint}</dd>}
96+ </div>
97+ ))}
98+ </dl>
99+ );
100+}
101+
102+/** Marks a derived metric and links to the methodology. */
103+export function Derived({ className }: { className?: string }) {
104+ return (
105+ <Link href={routes.methodology()} className={cn('inline-flex items-center rounded-sm border border-accent-2/40 px-1 text-[9px] font-semibold uppercase tracking-[0.12em] text-accent-2 no-underline hover:bg-accent-2-soft', className)} title="Derived metric — see methodology">
106+ derived
107+ </Link>
108+ );
109+}
110+
111+// ---------------------------------------------------------------------------------------------------- hero bits
112+export function HeroFacts({ items }: { items: { label: string; value: ReactNode }[] }) {
113+ const rows = items.filter((i) => i.value !== null && i.value !== undefined && i.value !== '—');
114+ if (!rows.length) return null;
115+ return (
116+ <dl className="mt-5 flex flex-wrap gap-x-8 gap-y-3 text-sm">
117+ {rows.map((i) => (
118+ <div key={i.label} className="min-w-0">
119+ <dt className="eyebrow">{i.label}</dt>
120+ <dd className="mt-0.5 text-ink">{i.value}</dd>
121+ </div>
122+ ))}
123+ </dl>
124+ );
125+}
126+
127+export function ExternalLink({ href, children }: { href: string; children?: ReactNode }) {
128+ let host = href;
129+ try {
130+ host = new URL(href).host.replace(/^www\./, '');
131+ } catch {
132+ /* keep raw */
133+ }
134+ return (
135+ <a href={href} target="_blank" rel="noopener noreferrer" className="link inline-flex items-center gap-1 text-sm">
136+ {children ?? host} <ExternalIcon className="size-3.5" aria-hidden />
137+ </a>
138+ );
139+}
140+
141+/** Small kind/service/stage label (uppercase mono). */
142+export function Tag({ children, tone = 'ink', className }: { children: ReactNode; tone?: 'ink' | 'accent' | 'accent-2' | 'warn'; className?: string }) {
143+ const tones = { ink: 'border-rule text-ink-2', accent: 'border-accent/40 text-accent', 'accent-2': 'border-accent-2/40 text-accent-2', warn: 'border-warn/40 text-warn' };
144+ return <span className={cn('mono inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] uppercase tracking-wide', tones[tone], className)}>{children}</span>;
145+}
146+
147+// ------------------------------------------------------------------------------------------------------ panels
148+/** Titled block used inside two-column layouts (lighter than <Section>). */
149+export function Block({ title, eyebrow, action, children, className, id }: { title: ReactNode; eyebrow?: string; action?: { href: string; label: string }; children: ReactNode; className?: string; id?: string }) {
150+ return (
151+ <section id={id} className={cn('py-6', className)}>
152+ <div className="mb-4 flex items-end justify-between gap-4">
153+ <div>
154+ {eyebrow && <p className="eyebrow">{eyebrow}</p>}
155+ <h2 className="text-lg font-semibold tracking-tight md:text-xl">{title}</h2>
156+ </div>
157+ {action && (
158+ <Link href={action.href} className="shrink-0 py-1 text-sm text-accent hover:underline">
159+ {action.label} →
160+ </Link>
161+ )}
162+ </div>
163+ {children}
164+ </section>
165+ );
166+}
167+
168+/** Planned-but-not-yet-connected data: honest state, never fabricated. */
169+export function PlannedUnavailable({ what, note }: { what: string; note: string }) {
170+ return (
171+ <div>
172+ <Unavailable what={what} />
173+ <p className="mt-2 text-xs text-ink-3">{note}</p>
174+ </div>
175+ );
176+}
177+
178+// ------------------------------------------------------------------------------------------------------- events
179+export function EventsList({ events, emptyWhat = 'Events' }: { events: EventRow[] | null | undefined; emptyWhat?: string }) {
180+ if (!events) return <Unavailable what={emptyWhat} />;
181+ if (!events.length) return <p className="text-sm text-ink-3">No events recorded for this entity yet.</p>;
182+ return (
183+ <ol className="divide-y divide-rule">
184+ {events.map((e) => (
185+ <li key={e.id} className="grid gap-1 py-3 sm:grid-cols-[150px_minmax(0,1fr)] sm:gap-4">
186+ <time dateTime={e.event_time} className="mono text-xs text-ink-3">{fmtDateTime(e.event_time)}</time>
187+ <div className="min-w-0">
188+ <div className="flex flex-wrap items-center gap-2">
189+ <Tag>{EVENT_TYPE_LABELS[e.type] ?? titleCase(e.type)}</Tag>
190+ <p className="text-sm font-medium text-ink">{e.title}</p>
191+ </div>
192+ {e.summary && <p className="mt-1 text-sm text-ink-2">{e.summary}</p>}
193+ <p className="mt-1 text-xs text-ink-3">
194+ {e.source_name ? `Source: ${e.source_name}` : 'Source unavailable'} · confidence {Math.round(e.confidence * 100)}%
195+ </p>
196+ </div>
197+ </li>
198+ ))}
199+ </ol>
200+ );
201+}
202+
203+// ------------------------------------------------------------------------------------------------------- tables
204+export function ScrollTable({ children, className }: { children: ReactNode; className?: string }) {
205+ return <div className={cn('-mx-1 overflow-x-auto px-1 md:mx-0 md:px-0', className)}>{children}</div>;
206+}
207+
208+export function EmptyRow({ colSpan, children }: { colSpan: number; children: ReactNode }) {
209+ return (
210+ <tr>
211+ <td colSpan={colSpan} className="py-6 text-center text-sm text-ink-3">{children}</td>
212+ </tr>
213+ );
214+}
added apps/web/src/components/entities/tables.tsx +249 −0
Large diff (249 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/earth.tsx +186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/filters.ts +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/geo.ts +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/satellite-points.tsx +225 −0
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/use-positions.ts +157 −0
Large diff (157 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/mobile-tab-bar.tsx +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/search-context.tsx +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/search-dialog.tsx +169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/site-footer.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/layout/site-header.tsx +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/map/world-map.tsx +91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/altitude-chart.tsx +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/hero.tsx +129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/live-map.tsx +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/primitives.tsx +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/view-beacon.tsx +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/local-charts.tsx +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/shared.tsx +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/stats-composition.tsx +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/stats-headline.tsx +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/stats-history.tsx +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/badges.tsx +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/pagination.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/section.tsx +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/unavailable.tsx +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api.ts +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/client-api.ts +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/cn.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/fonts.system.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/fonts.ts +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/format.ts +90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/site.ts +118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/types.ts +559 −0
Large diff (559 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/tsconfig.json +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added deploy/first-run.sh +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added deploy/render-manifest.sh +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added deploy/satelliteindex.mld.json +113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added docs/API.md +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added docs/DEPLOY.md +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added docs/FRONTEND-GUIDE.md +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added migrations/env.py +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added migrations/script.py.mako +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added migrations/versions/0001_initial.py +594 −0
Large diff (594 lines) is not loaded by default to keep the page fast.Load diff
added package.json +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-lock.yaml +1796 −0
Large diff (1,796 lines) is not loaded by default to keep the page fast.Load diff
added pnpm-workspace.yaml +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added pyproject.toml +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added scripts/backup-offnode.sh +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/__init__.py +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/analytics/__init__.py +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/analytics/search.py +81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/analytics/stats.py +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/common.py +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/main.py +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/ratelimit.py +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/admin.py +177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/constellations.py +71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/countries.py +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/events.py +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/health.py +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/launches.py +159 −0
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/misc.py +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/operators.py +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/orbit.py +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/satellites.py +251 −0
Large diff (251 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/search.py +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/api/routers/stats.py +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/cli.py +204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/config.py +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/__init__.py +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/base/__init__.py +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/base/connector.py +179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/base/fetch.py +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/derived.py +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/orbital/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/orbital/celestrak/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/orbital/celestrak/gp.py +265 −0
Large diff (265 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/connectors/orbital/celestrak/satcat.py +274 −0
Large diff (274 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/db/__init__.py +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/ids.py +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/logging.py +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/orbital/__init__.py +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/orbital/elements.py +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/orbital/propagate.py +147 −0
Large diff (147 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/registry/__init__.py +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/registry/constellations.yaml +925 −0
Large diff (925 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/registry/owners.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/registry/reference.py +142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/registry/seed.py +184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/registry/sites.json +1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/services/__init__.py +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/services/cache.py +156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/services/classify.py +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/services/events.py +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/services/positions.py +113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/services/resolution.py +204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/worker/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added src/satelliteindex/worker/scheduler.py +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/celestrak_stations.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added tests/fixtures/satcat_head.csv +200 −0
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_api.py +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_connectors.py +65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_orbital.py +94 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme