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%

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 13 days ago (Sep 11, 2026)

128 changed files +13,601 −0

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
@@ -0,0 +1,249 @@
1 +/** Reusable data tables for entity detail pages — all `.data-table.stack` (stacked cards under 768 px). */
2 +import Link from 'next/link';
3 +import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';
4 +import { MissionLabel } from '@/components/ui/badges';
5 +import { fmtDate, fmtInt, fmtKm, fmtDeg, titleCase } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import type { Num } from '@/lib/types';
8 +import { EmptyRow, ScrollTable } from './shared';
9 +
10 +// ---------------------------------------------------------------------------------------------------- launches
11 +export interface LaunchLike {
12 + id: string;
13 + cospar_launch_id: string;
14 + launch_date: string | null;
15 + site_name: string | null;
16 + site_slug: string | null;
17 + satellites?: Num;
18 + payload_count?: Num;
19 + active?: Num;
20 + primary_name?: string | null;
21 +}
22 +
23 +export function LaunchesTable({ rows, showActive = false, showPrimary = false }: { rows: LaunchLike[]; showActive?: boolean; showPrimary?: boolean }) {
24 + const cols = 4 + (showActive ? 1 : 0) + (showPrimary ? 1 : 0);
25 + return (
26 + <ScrollTable>
27 + <table className="data-table stack">
28 + <thead>
29 + <tr>
30 + <th>Launch</th>
31 + <th>Date</th>
32 + {showPrimary && <th>Primary payload</th>}
33 + <th>Site</th>
34 + <th className="num">{showPrimary ? 'Payloads' : 'Satellites'}</th>
35 + {showActive && <th className="num">Active</th>}
36 + </tr>
37 + </thead>
38 + <tbody>
39 + {rows.length === 0 && <EmptyRow colSpan={cols}>No launches recorded.</EmptyRow>}
40 + {rows.map((l) => (
41 + <tr key={l.id}>
42 + <td className="primary" data-label="Launch">
43 + <Link href={routes.launch(l.cospar_launch_id)} className="link mono">{l.cospar_launch_id}</Link>
44 + </td>
45 + <td data-label="Date" className="tnum text-ink-2">{fmtDate(l.launch_date)}</td>
46 + {showPrimary && <td data-label="Primary payload" className="text-ink-2">{l.primary_name ?? '—'}</td>}
47 + <td data-label="Site" className="max-w-[360px] text-ink-2">
48 + {l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name ?? l.site_slug}</Link> : l.site_name ?? '—'}
49 + </td>
50 + <td data-label={showPrimary ? 'Payloads' : 'Satellites'} className="num tnum">{fmtInt(l.satellites ?? l.payload_count)}</td>
51 + {showActive && <td data-label="Active" className="num tnum text-active">{fmtInt(l.active)}</td>}
52 + </tr>
53 + ))}
54 + </tbody>
55 + </table>
56 + </ScrollTable>
57 + );
58 +}
59 +
60 +// ------------------------------------------------------------------------------------------------ launch sites
61 +export interface SiteLike {
62 + code: string;
63 + name: string;
64 + slug: string;
65 + country_code?: string | null;
66 + launches: Num;
67 + satellites?: Num;
68 + last_launch?: string | null;
69 +}
70 +
71 +export function SitesTable({ rows, showSatellites = false, showLast = false }: { rows: SiteLike[]; showSatellites?: boolean; showLast?: boolean }) {
72 + const cols = 3 + (showSatellites ? 1 : 0) + (showLast ? 1 : 0);
73 + return (
74 + <ScrollTable>
75 + <table className="data-table stack">
76 + <thead>
77 + <tr>
78 + <th>Site</th>
79 + <th>Code</th>
80 + <th className="num">Launches</th>
81 + {showSatellites && <th className="num">Satellites</th>}
82 + {showLast && <th>Last launch</th>}
83 + </tr>
84 + </thead>
85 + <tbody>
86 + {rows.length === 0 && <EmptyRow colSpan={cols}>No launch sites recorded.</EmptyRow>}
87 + {rows.map((s) => (
88 + <tr key={s.code}>
89 + <td className="primary" data-label="Site">
90 + <Link href={routes.launchSite(s.slug)} className="link">{s.name}</Link>
91 + {s.country_code && <span className="mono ml-2 text-xs text-ink-3">{s.country_code}</span>}
92 + </td>
93 + <td data-label="Code" className="mono text-xs text-ink-2">{s.code}</td>
94 + <td data-label="Launches" className="num tnum">{fmtInt(s.launches)}</td>
95 + {showSatellites && <td data-label="Satellites" className="num tnum">{fmtInt(s.satellites)}</td>}
96 + {showLast && <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(s.last_launch)}</td>}
97 + </tr>
98 + ))}
99 + </tbody>
100 + </table>
101 + </ScrollTable>
102 + );
103 +}
104 +
105 +// -------------------------------------------------------------------------------------------------- satellites
106 +export interface SatLike {
107 + id: string;
108 + slug: string;
109 + name: string;
110 + norad_id: number | null;
111 + status: string;
112 + object_type?: string;
113 + orbit_class?: string | null;
114 + mission_type?: string | null;
115 + launch_date: string | null;
116 + perigee_km?: Num;
117 + apogee_km?: Num;
118 + inclination_deg?: Num;
119 +}
120 +
121 +export function SatellitesTable({ rows, columns }: { rows: SatLike[]; columns: ('type' | 'orbit' | 'mission' | 'perigee' | 'apogee' | 'inclination')[] }) {
122 + const has = (c: (typeof columns)[number]) => columns.includes(c);
123 + const cols = 4 + columns.length;
124 + return (
125 + <ScrollTable>
126 + <table className="data-table stack">
127 + <thead>
128 + <tr>
129 + <th>Satellite</th>
130 + <th>NORAD</th>
131 + <th>Status</th>
132 + {has('type') && <th>Type</th>}
133 + {has('orbit') && <th>Orbit</th>}
134 + {has('mission') && <th>Mission</th>}
135 + <th>Launched</th>
136 + {has('perigee') && <th className="num">Perigee</th>}
137 + {has('apogee') && <th className="num">Apogee</th>}
138 + {has('inclination') && <th className="num">Incl.</th>}
139 + </tr>
140 + </thead>
141 + <tbody>
142 + {rows.length === 0 && <EmptyRow colSpan={cols}>No satellites recorded.</EmptyRow>}
143 + {rows.map((s) => (
144 + <tr key={s.id}>
145 + <td className="primary" data-label="Satellite">
146 + <Link href={routes.satellite(s.slug)} className="link font-medium">{s.name}</Link>
147 + </td>
148 + <td data-label="NORAD" className="mono text-xs text-ink-2">{s.norad_id ?? '—'}</td>
149 + <td data-label="Status"><StatusBadge status={s.status} /></td>
150 + {has('type') && <td data-label="Type"><TypeBadge type={s.object_type} /></td>}
151 + {has('orbit') && <td data-label="Orbit"><OrbitBadge orbitClass={s.orbit_class} /></td>}
152 + {has('mission') && <td data-label="Mission" className="text-ink-2"><MissionLabel mission={s.mission_type} /></td>}
153 + <td data-label="Launched" className="tnum text-ink-2">{fmtDate(s.launch_date)}</td>
154 + {has('perigee') && <td data-label="Perigee" className="num tnum">{fmtKm(s.perigee_km)}</td>}
155 + {has('apogee') && <td data-label="Apogee" className="num tnum">{fmtKm(s.apogee_km)}</td>}
156 + {has('inclination') && <td data-label="Incl." className="num tnum">{fmtDeg(s.inclination_deg)}</td>}
157 + </tr>
158 + ))}
159 + </tbody>
160 + </table>
161 + </ScrollTable>
162 + );
163 +}
164 +
165 +// ------------------------------------------------------------------------------------------ constellation lists
166 +export interface ConstellationLike {
167 + id: string;
168 + slug: string;
169 + name: string;
170 + service_type: string | null;
171 + orbit_class: string | null;
172 + active: Num;
173 + total: Num;
174 + launched_last_365d?: Num;
175 +}
176 +
177 +export function ConstellationsMiniTable({ rows }: { rows: ConstellationLike[] }) {
178 + const showGrowth = rows.some((r) => r.launched_last_365d !== undefined);
179 + return (
180 + <ScrollTable>
181 + <table className="data-table stack">
182 + <thead>
183 + <tr>
184 + <th>Constellation</th>
185 + <th>Service</th>
186 + <th>Orbit</th>
187 + <th className="num">Active</th>
188 + <th className="num">Total</th>
189 + {showGrowth && <th className="num">365 d</th>}
190 + </tr>
191 + </thead>
192 + <tbody>
193 + {rows.length === 0 && <EmptyRow colSpan={showGrowth ? 6 : 5}>No constellations linked.</EmptyRow>}
194 + {rows.map((c) => (
195 + <tr key={c.id}>
196 + <td className="primary" data-label="Constellation"><Link href={routes.constellation(c.slug)} className="link font-medium">{c.name}</Link></td>
197 + <td data-label="Service" className="text-ink-2">{titleCase(c.service_type)}</td>
198 + <td data-label="Orbit"><OrbitBadge orbitClass={c.orbit_class} /></td>
199 + <td data-label="Active" className="num tnum text-active">{fmtInt(c.active)}</td>
200 + <td data-label="Total" className="num tnum">{fmtInt(c.total)}</td>
201 + {showGrowth && <td data-label="Launched 365 d" className="num tnum">{fmtInt(c.launched_last_365d)}</td>}
202 + </tr>
203 + ))}
204 + </tbody>
205 + </table>
206 + </ScrollTable>
207 + );
208 +}
209 +
210 +// ----------------------------------------------------------------------------------------------- operator lists
211 +export interface OperatorLike {
212 + id: string;
213 + slug: string;
214 + name: string;
215 + kind: string;
216 + active_payloads: Num;
217 + total_payloads: Num;
218 + payloads_last_365d: Num;
219 +}
220 +
221 +export function OperatorsMiniTable({ rows }: { rows: OperatorLike[] }) {
222 + return (
223 + <ScrollTable>
224 + <table className="data-table stack">
225 + <thead>
226 + <tr>
227 + <th>Operator</th>
228 + <th>Kind</th>
229 + <th className="num">Active</th>
230 + <th className="num">Total</th>
231 + <th className="num">365 d</th>
232 + </tr>
233 + </thead>
234 + <tbody>
235 + {rows.length === 0 && <EmptyRow colSpan={5}>No operators linked.</EmptyRow>}
236 + {rows.map((o) => (
237 + <tr key={o.id}>
238 + <td className="primary" data-label="Operator"><Link href={routes.operator(o.slug)} className="link font-medium">{o.name}</Link></td>
239 + <td data-label="Kind" className="text-ink-2">{titleCase(o.kind)}</td>
240 + <td data-label="Active" className="num tnum text-active">{fmtInt(o.active_payloads)}</td>
241 + <td data-label="Total" className="num tnum">{fmtInt(o.total_payloads)}</td>
242 + <td data-label="Launched 365 d" className="num tnum">{fmtInt(o.payloads_last_365d)}</td>
243 + </tr>
244 + ))}
245 + </tbody>
246 + </table>
247 + </ScrollTable>
248 + );
249 +}
added apps/web/src/components/globe/earth.tsx +186 −0
@@ -0,0 +1,186 @@
1 +'use client';
2 +/**
3 + * Vector Earth: dark sphere shaded by the real sun direction (day/night terminator), fresnel atmosphere, and
4 + * Natural Earth 110 m land + country outlines drawn as GPU line segments (no raster texture — a premium, data-first look).
5 + */
6 +import { useFrame } from '@react-three/fiber';
7 +import { useMemo, useRef } from 'react';
8 +import * as THREE from 'three';
9 +import { mesh as topoMesh } from 'topojson-client';
10 +import type { Topology } from 'topojson-specification';
11 +import countries110 from 'world-atlas/countries-110m.json';
12 +import land110 from 'world-atlas/land-110m.json';
13 +import { llaToXyz, prefersReducedMotion, sunDirection, token } from './geo';
14 +
15 +const R_LAND = 1.003;
16 +
17 +type MultiLine = { type: string; coordinates: number[][][] };
18 +
19 +function linesToSegments(ml: MultiLine, r: number): Float32Array {
20 + let segs = 0;
21 + for (const line of ml.coordinates) segs += Math.max(0, line.length - 1);
22 + const out = new Float32Array(segs * 6);
23 + let o = 0;
24 + const a = new Float32Array(3);
25 + const b = new Float32Array(3);
26 + for (const line of ml.coordinates) {
27 + for (let i = 0; i < line.length - 1; i++) {
28 + const p = line[i]!;
29 + const q = line[i + 1]!;
30 + llaToXyz(p[1] ?? 0, p[0] ?? 0, r, a);
31 + llaToXyz(q[1] ?? 0, q[0] ?? 0, r, b);
32 + out.set(a, o);
33 + out.set(b, o + 3);
34 + o += 6;
35 + }
36 + }
37 + return out;
38 +}
39 +
40 +function graticule(stepDeg: number, r: number): Float32Array {
41 + const pts: number[] = [];
42 + const v = new Float32Array(3);
43 + const push = (lat: number, lon: number) => {
44 + llaToXyz(lat, lon, r, v);
45 + pts.push(v[0]!, v[1]!, v[2]!);
46 + };
47 + for (let lon = -180; lon < 180; lon += stepDeg) {
48 + for (let lat = -90; lat < 90; lat += 3) {
49 + push(lat, lon);
50 + push(lat + 3, lon);
51 + }
52 + }
53 + for (let lat = -60; lat <= 60; lat += stepDeg) {
54 + for (let lon = -180; lon < 180; lon += 3) {
55 + push(lat, lon);
56 + push(lat, lon + 3);
57 + }
58 + }
59 + return new Float32Array(pts);
60 +}
61 +
62 +function lineGeometry(arr: Float32Array): THREE.BufferGeometry {
63 + const g = new THREE.BufferGeometry();
64 + g.setAttribute('position', new THREE.BufferAttribute(arr, 3));
65 + return g;
66 +}
67 +
68 +const EARTH_VERT = /* glsl */ `
69 + varying vec3 vNormalW;
70 + varying vec3 vPosW;
71 + void main() {
72 + vNormalW = normalize(mat3(modelMatrix) * normal);
73 + vPosW = (modelMatrix * vec4(position, 1.0)).xyz;
74 + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
75 + }
76 +`;
77 +const EARTH_FRAG = /* glsl */ `
78 + uniform vec3 uSun;
79 + uniform vec3 uNight;
80 + uniform vec3 uDay;
81 + uniform vec3 uRim;
82 + varying vec3 vNormalW;
83 + varying vec3 vPosW;
84 + void main() {
85 + vec3 n = normalize(vNormalW);
86 + float l = dot(n, uSun);
87 + float day = smoothstep(-0.12, 0.28, l);
88 + vec3 col = mix(uNight, uDay, day);
89 + vec3 viewDir = normalize(cameraPosition - vPosW);
90 + float rim = pow(1.0 - max(dot(n, viewDir), 0.0), 3.0);
91 + col += uRim * rim * 0.35;
92 + gl_FragColor = vec4(col, 1.0);
93 + }
94 +`;
95 +const ATMO_VERT = /* glsl */ `
96 + varying vec3 vNormalW;
97 + varying vec3 vPosW;
98 + void main() {
99 + vNormalW = normalize(mat3(modelMatrix) * normal);
100 + vPosW = (modelMatrix * vec4(position, 1.0)).xyz;
101 + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
102 + }
103 +`;
104 +const ATMO_FRAG = /* glsl */ `
105 + uniform vec3 uColor;
106 + varying vec3 vNormalW;
107 + varying vec3 vPosW;
108 + void main() {
109 + vec3 viewDir = normalize(cameraPosition - vPosW);
110 + float f = pow(max(dot(normalize(vNormalW), viewDir), 0.0), 2.6);
111 + gl_FragColor = vec4(uColor, f * 0.55);
112 + }
113 +`;
114 +
115 +export function Earth({ quality = 'high' }: { quality?: 'high' | 'low' }) {
116 + const sunRef = useRef(new THREE.Vector3(1, 0, 0));
117 + const lastSun = useRef(0);
118 + const reduced = prefersReducedMotion();
119 +
120 + const { landGeo, borderGeo, gratGeo, earthMat, atmoMat, landColor, borderColor, gratColor } = useMemo(() => {
121 + const landTopo = land110 as unknown as Topology;
122 + const cTopo = countries110 as unknown as Topology;
123 + const land = topoMesh(landTopo, landTopo.objects.land as never) as unknown as MultiLine;
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 + const accent = new THREE.Color(token('--accent'));
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);
128 + const rim = new THREE.Color(token('--accent')).multiplyScalar(0.6);
129 + return {
130 + landGeo: lineGeometry(linesToSegments(land, R_LAND)),
131 + borderGeo: lineGeometry(linesToSegments(borders, R_LAND)),
132 + gratGeo: lineGeometry(graticule(30, 1.001)),
133 + earthMat: new THREE.ShaderMaterial({
134 + vertexShader: EARTH_VERT,
135 + fragmentShader: EARTH_FRAG,
136 + uniforms: { uSun: { value: sunRef.current }, uNight: { value: night }, uDay: { value: day }, uRim: { value: rim } },
137 + }),
138 + atmoMat: new THREE.ShaderMaterial({
139 + vertexShader: ATMO_VERT,
140 + fragmentShader: ATMO_FRAG,
141 + uniforms: { uColor: { value: accent } },
142 + transparent: true,
143 + side: THREE.BackSide,
144 + depthWrite: false,
145 + blending: THREE.AdditiveBlending,
146 + }),
147 + landColor: new THREE.Color(token('--accent')).lerp(new THREE.Color('#ffffff'), 0.25),
148 + borderColor: new THREE.Color(token('--ink-3')),
149 + gratColor: new THREE.Color(token('--ink-3')),
150 + };
151 + }, []);
152 +
153 + useFrame(() => {
154 + const now = Date.now();
155 + if (now - lastSun.current > (reduced ? 60_000 : 1_000)) {
156 + lastSun.current = now;
157 + sunDirection(new Date(now), sunRef.current);
158 + }
159 + });
160 +
161 + const seg = quality === 'high' ? 96 : 48;
162 + return (
163 + <group>
164 + <mesh material={earthMat} renderOrder={0}>
165 + <sphereGeometry args={[1, seg, seg / 2]} />
166 + </mesh>
167 + <mesh material={atmoMat} renderOrder={1} scale={1.045}>
168 + <sphereGeometry args={[1, 64, 32]} />
169 + </mesh>
170 + <lineSegments geometry={gratGeo} renderOrder={2}>
171 + <lineBasicMaterial color={gratColor} transparent opacity={0.16} depthWrite={false} />
172 + </lineSegments>
173 + <lineSegments geometry={landGeo} renderOrder={3}>
174 + <lineBasicMaterial color={landColor} transparent opacity={0.85} depthWrite={false} />
175 + </lineSegments>
176 + <lineSegments geometry={borderGeo} renderOrder={3}>
177 + <lineBasicMaterial color={borderColor} transparent opacity={0.35} depthWrite={false} />
178 + </lineSegments>
179 + {/* Equator hairline helps read the GEO ring geometry */}
180 + <mesh rotation={[Math.PI / 2, 0, 0]} renderOrder={2}>
181 + <ringGeometry args={[1.0005, 1.0035, 128]} />
182 + <meshBasicMaterial color={gratColor} transparent opacity={0.25} side={THREE.DoubleSide} depthWrite={false} />
183 + </mesh>
184 + </group>
185 + );
186 +}
added apps/web/src/components/globe/filters.ts +46 −0
@@ -0,0 +1,46 @@
1 +/** Globe filter model: a set of orbit classes, a set of mission types and an active-only switch. */
2 +import type { GlobeData } from './use-positions';
3 +
4 +export const ORBIT_CLASSES = ['LEO', 'MEO', 'GEO', 'HEO'] as const;
5 +export const MISSIONS = ['communications', 'earth-observation', 'navigation', 'weather', 'science', 'military', 'technology', 'iot', 'station'] as const;
6 +
7 +export interface GlobeFilters {
8 + cls: Set<string>; // empty = all
9 + mission: Set<string>; // empty = all
10 + activeOnly: boolean;
11 +}
12 +
13 +export const DEFAULT_FILTERS: GlobeFilters = { cls: new Set(), mission: new Set(), activeOnly: false };
14 +
15 +export function toggleIn(set: Set<string>, v: string): Set<string> {
16 + const next = new Set(set);
17 + if (next.has(v)) next.delete(v);
18 + else next.add(v);
19 + return next;
20 +}
21 +
22 +export function activeFilterCount(f: GlobeFilters): number {
23 + return f.cls.size + f.mission.size + (f.activeOnly ? 1 : 0);
24 +}
25 +
26 +/** Compute the 0/1 visibility flag per rendered object. Returns the flags and the number of visible objects. */
27 +export function computeVisibility(d: GlobeData, f: GlobeFilters, out: Float32Array): number {
28 + const clsAllowed = d.legend.cls.map((c) => f.cls.size === 0 || f.cls.has(c));
29 + const misAllowed = d.legend.mission.map((m) => f.mission.size === 0 || f.mission.has(m));
30 + let visible = 0;
31 + for (let i = 0; i < d.n; i++) {
32 + const ok = (clsAllowed[d.cls[i] ?? 0] ?? true) && (misAllowed[d.mission[i] ?? 0] ?? true) && (!f.activeOnly || d.active[i] === 1);
33 + out[i] = ok ? 1 : 0;
34 + if (ok) visible++;
35 + }
36 + return visible;
37 +}
38 +
39 +/** Count visible objects over the *full* snapshot counts is not possible after capping — we report the rendered subset honestly. */
40 +export function describeSelection(f: GlobeFilters): string {
41 + const parts: string[] = [];
42 + if (f.cls.size) parts.push([...f.cls].join('/'));
43 + if (f.mission.size) parts.push(`${f.mission.size} mission type${f.mission.size > 1 ? 's' : ''}`);
44 + if (f.activeOnly) parts.push('active only');
45 + return parts.length ? parts.join(' · ') : 'all tracked objects';
46 +}
added apps/web/src/components/globe/geo.ts +105 −0
@@ -0,0 +1,105 @@
1 +/**
2 + * Geometry helpers shared by the globe: geodetic → unit-sphere coordinates (Three.js is Y-up), the compressed
3 + * altitude scale used for legibility, sun direction for the day/night terminator and CSS-token colour lookup.
4 + *
5 + * Altitude scale (documented in the UI tooltip): r = 1 + 0.06 + ln(1 + alt/400) × 0.12 with alt in km.
6 + * LEO (400 km) → 1.143 · 1 000 km → 1.21 · MEO (20 000 km) → 1.53 · GEO (35 786 km) → 1.60. It exaggerates low
7 + * shells so that the LEO swarm does not sit on the surface, and compresses MEO/GEO so the GEO ring stays on screen.
8 + */
9 +import type { Vector3 } from 'three';
10 +
11 +export const EARTH_RADIUS_KM = 6371;
12 +export const DEG = Math.PI / 180;
13 +
14 +export function altToRadius(altKm: number): number {
15 + const a = Math.max(0, altKm);
16 + return 1 + 0.06 + Math.log1p(a / 400) * 0.12;
17 +}
18 +
19 +/** lon/lat (deg) at radius r → xyz (Y-up, prime meridian at +X, east positive). Writes into `out` (Float32Array|number[]). */
20 +export function llaToXyz(latDeg: number, lonDeg: number, r: number, out: Float32Array | number[], off = 0): void {
21 + const lat = latDeg * DEG;
22 + const lon = lonDeg * DEG;
23 + const c = Math.cos(lat);
24 + out[off] = r * c * Math.cos(lon);
25 + out[off + 1] = r * Math.sin(lat);
26 + out[off + 2] = -r * c * Math.sin(lon);
27 +}
28 +
29 +export function llaToVec(latDeg: number, lonDeg: number, r: number, v: Vector3): Vector3 {
30 + const lat = latDeg * DEG;
31 + const lon = lonDeg * DEG;
32 + const c = Math.cos(lat);
33 + return v.set(r * c * Math.cos(lon), r * Math.sin(lat), -r * c * Math.sin(lon));
34 +}
35 +
36 +/**
37 + * Sub-solar point (approximate, ±0.5°): low-precision solar coordinates (Astronomical Almanac) + GMST.
38 + * Returns a unit vector in the same Y-up frame as `llaToXyz` — cheap enough to recompute every frame.
39 + */
40 +export function sunDirection(date: Date, out: Vector3): Vector3 {
41 + const jd = date.getTime() / 86400000 + 2440587.5;
42 + const d = jd - 2451545.0;
43 + const g = ((357.529 + 0.98560028 * d) % 360) * DEG;
44 + const q = (280.459 + 0.98564736 * d) % 360;
45 + const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * DEG;
46 + const e = (23.439 - 0.00000036 * d) * DEG;
47 + const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
48 + const dec = Math.asin(Math.sin(e) * Math.sin(L));
49 + const gmst = ((280.46061837 + 360.98564736629 * d) % 360) * DEG;
50 + const lon = ra - gmst; // sub-solar longitude (rad)
51 + const c = Math.cos(dec);
52 + return out.set(c * Math.cos(lon), Math.sin(dec), -c * Math.sin(lon)).normalize();
53 +}
54 +
55 +/** Resolve a CSS custom property (design token) to a colour string usable by Three. */
56 +export function cssVar(name: string, fallback: string): string {
57 + if (typeof window === 'undefined') return fallback;
58 + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
59 + return v || fallback;
60 +}
61 +
62 +export const TOKEN_FALLBACKS: Record<string, string> = {
63 + '--leo': '#38d3ff',
64 + '--meo': '#8f7dff',
65 + '--geo': '#f5b544',
66 + '--heo': '#ff7ab6',
67 + '--other': '#7c869e',
68 + '--accent': '#38d3ff',
69 + '--accent-2': '#8f7dff',
70 + '--ink-3': '#6b7694',
71 + '--rule-strong': 'rgba(160, 180, 230, 0.28)',
72 + '--plane': '#0b1020',
73 + '--plane-2': '#111830',
74 + '--space': '#060912',
75 +};
76 +
77 +export function token(name: keyof typeof TOKEN_FALLBACKS): string {
78 + return cssVar(name, TOKEN_FALLBACKS[name] ?? '#ffffff');
79 +}
80 +
81 +/** Detect WebGL support without creating a persistent context (headless / old GPUs / blocked GL). */
82 +export function hasWebGL(): boolean {
83 + if (typeof window === 'undefined') return false;
84 + try {
85 + const c = document.createElement('canvas');
86 + const gl = c.getContext('webgl2') ?? c.getContext('webgl');
87 + if (!gl) return false;
88 + const ext = gl.getExtension('WEBGL_lose_context');
89 + ext?.loseContext();
90 + return true;
91 + } catch {
92 + return false;
93 + }
94 +}
95 +
96 +export function prefersReducedMotion(): boolean {
97 + return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
98 +}
99 +
100 +/** Low-power heuristic: cap rendered points on small screens / few cores. */
101 +export function isLowPower(): boolean {
102 + if (typeof window === 'undefined') return false;
103 + const cores = navigator.hardwareConcurrency ?? 8;
104 + return cores <= 4 || window.innerWidth < 768;
105 +}
added apps/web/src/components/globe/satellite-points.tsx +225 −0
@@ -0,0 +1,225 @@
1 +'use client';
2 +/**
3 + * All satellites as ONE THREE.Points draw call. Positions are lerped every frame between the two SGP4 endpoints of
4 + * the snapshot (t0 → t1) in Cartesian space; visibility/size/alpha are per-vertex attributes so filtering never
5 + * rebuilds geometry. Picking is done on the CPU in screen space against the same buffer (with globe occlusion).
6 + */
7 +import { useFrame, useThree } from '@react-three/fiber';
8 +import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
9 +import * as THREE from 'three';
10 +import { token } from './geo';
11 +import type { GlobeData } from './use-positions';
12 +
13 +const VERT = /* glsl */ `
14 + uniform float uScale;
15 + attribute float aSize;
16 + attribute float aAlpha;
17 + attribute float aFlag;
18 + varying vec3 vColor;
19 + varying float vAlpha;
20 + void main() {
21 + vColor = color;
22 + vAlpha = aAlpha;
23 + vec4 mv = modelViewMatrix * vec4(position, 1.0);
24 + gl_Position = projectionMatrix * mv;
25 + gl_PointSize = aSize * uScale / max(-mv.z, 0.2);
26 + if (aFlag < 0.5) { gl_PointSize = 0.0; gl_Position = vec4(2.0, 2.0, 2.0, 1.0); }
27 + }
28 +`;
29 +const FRAG = /* glsl */ `
30 + varying vec3 vColor;
31 + varying float vAlpha;
32 + void main() {
33 + float d = length(gl_PointCoord - 0.5);
34 + float a = smoothstep(0.5, 0.18, d) * vAlpha;
35 + if (a < 0.02) discard;
36 + gl_FragColor = vec4(vColor, a);
37 + }
38 +`;
39 +
40 +export interface PickResult {
41 + index: number;
42 + norad: number;
43 +}
44 +
45 +export interface PointsProps {
46 + data: GlobeData;
47 + /** 0/1 per rendered object (length ≥ data.n). */
48 + flags: Float32Array;
49 + /** Bumped whenever `flags` changes so the attribute uploads. */
50 + flagsVersion: number;
51 + highlight: number | null; // index into data
52 + /** Written every frame with the highlighted object's current position (for the camera rig / track). */
53 + highlightPos: MutableRefObject<THREE.Vector3>;
54 + onPick?: (r: PickResult | null) => void;
55 + pointScale?: number;
56 +}
57 +
58 +/** Interpolation factor from the wall clock; allowed to extrapolate a little when a refresh is late. */
59 +export function lerpFactor(d: GlobeData, now: number): number {
60 + const span = Math.max(1, d.t1 - d.t0);
61 + return Math.min(3, Math.max(0, (now - d.t0) / span));
62 +}
63 +
64 +export function SatellitePoints({ data, flags, flagsVersion, highlight, highlightPos, onPick, pointScale = 1 }: PointsProps) {
65 + const { gl, camera, size } = useThree();
66 + const pointsRef = useRef<THREE.Points>(null);
67 + const ringRef = useRef<THREE.Mesh>(null);
68 + const posAttr = useRef<THREE.BufferAttribute | null>(null);
69 + const cur = useRef<Float32Array>(new Float32Array(0));
70 +
71 + const { geometry, material } = useMemo(() => {
72 + const n = data.n;
73 + const positions = new Float32Array(n * 3);
74 + positions.set(data.p0);
75 + cur.current = positions;
76 + const colors = new Float32Array(n * 3);
77 + const sizes = new Float32Array(n);
78 + const alphas = new Float32Array(n);
79 + const palette = ['--leo', '--meo', '--geo', '--heo', '--other'].map((t) => new THREE.Color(token(t as '--leo')));
80 + const stationIdx = data.legend.mission.indexOf('station');
81 + for (let i = 0; i < n; i++) {
82 + const c = palette[data.cls[i] ?? 4] ?? palette[4]!;
83 + const active = data.active[i] === 1;
84 + const isLeo = (data.cls[i] ?? 0) === 0;
85 + const isStation = stationIdx >= 0 && data.mission[i] === stationIdx;
86 + colors[i * 3] = c.r;
87 + colors[i * 3 + 1] = c.g;
88 + colors[i * 3 + 2] = c.b;
89 + sizes[i] = (isStation ? 4.2 : isLeo ? 2.1 : 2.9) * (active ? 1 : 0.8);
90 + alphas[i] = active ? (isLeo ? 0.85 : 0.95) : 0.35;
91 + }
92 + const g = new THREE.BufferGeometry();
93 + const pa = new THREE.BufferAttribute(positions, 3);
94 + pa.setUsage(THREE.DynamicDrawUsage);
95 + posAttr.current = pa;
96 + g.setAttribute('position', pa);
97 + g.setAttribute('color', new THREE.BufferAttribute(colors, 3));
98 + g.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
99 + g.setAttribute('aAlpha', new THREE.BufferAttribute(alphas, 1));
100 + g.setAttribute('aFlag', new THREE.BufferAttribute(new Float32Array(n).fill(1), 1));
101 + g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.2);
102 + const m = new THREE.ShaderMaterial({
103 + vertexShader: VERT,
104 + fragmentShader: FRAG,
105 + uniforms: { uScale: { value: 1 } },
106 + vertexColors: true,
107 + transparent: true,
108 + depthWrite: false,
109 + depthTest: true,
110 + });
111 + return { geometry: g, material: m };
112 + }, [data]);
113 +
114 + useEffect(() => () => {
115 + geometry.dispose();
116 + material.dispose();
117 + }, [geometry, material]);
118 +
119 + // Upload visibility flags when filters change.
120 + useEffect(() => {
121 + const attr = geometry.getAttribute('aFlag') as THREE.BufferAttribute;
122 + (attr.array as Float32Array).set(flags.subarray(0, data.n));
123 + attr.needsUpdate = true;
124 + }, [flags, flagsVersion, geometry, data.n]);
125 +
126 + useFrame(() => {
127 + const d = data;
128 + const f = lerpFactor(d, Date.now());
129 + const p = cur.current;
130 + const { p0, p1 } = d;
131 + for (let i = 0; i < p.length; i++) p[i] = p0[i]! + (p1[i]! - p0[i]!) * f;
132 + if (posAttr.current) posAttr.current.needsUpdate = true;
133 + material.uniforms.uScale!.value = 4.6 * Math.min(gl.getPixelRatio(), 2) * pointScale;
134 +
135 + if (highlight !== null && highlight >= 0 && highlight < d.n) {
136 + const i3 = highlight * 3;
137 + highlightPos.current.set(p[i3]!, p[i3 + 1]!, p[i3 + 2]!);
138 + if (ringRef.current) {
139 + ringRef.current.visible = true;
140 + ringRef.current.position.copy(highlightPos.current);
141 + ringRef.current.quaternion.copy(camera.quaternion);
142 + const dist = camera.position.distanceTo(highlightPos.current);
143 + const s = 0.012 * dist * (1 + 0.15 * Math.sin(Date.now() / 250));
144 + ringRef.current.scale.setScalar(s);
145 + }
146 + } else if (ringRef.current) ringRef.current.visible = false;
147 + });
148 +
149 + // CPU picking in screen space (click/tap without drag), occluded by the globe.
150 + useEffect(() => {
151 + if (!onPick) return;
152 + const el = gl.domElement;
153 + let sx = 0;
154 + let sy = 0;
155 + let st = 0;
156 + const v = new THREE.Vector3();
157 + const down = (e: PointerEvent) => {
158 + sx = e.clientX;
159 + sy = e.clientY;
160 + st = performance.now();
161 + };
162 + const up = (e: PointerEvent) => {
163 + if (Math.hypot(e.clientX - sx, e.clientY - sy) > 6 || performance.now() - st > 600) return;
164 + const rect = el.getBoundingClientRect();
165 + const px = e.clientX - rect.left;
166 + const py = e.clientY - rect.top;
167 + const tol = (e.pointerType === 'touch' ? 22 : 12) ** 2;
168 + const p = cur.current;
169 + const cam = camera.position;
170 + const camLen2 = cam.lengthSq();
171 + let best = -1;
172 + let bestD = Infinity;
173 + let bestScore = Infinity;
174 + for (let i = 0; i < data.n; i++) {
175 + if ((flags[i] ?? 1) < 0.5) continue;
176 + v.set(p[i * 3]!, p[i * 3 + 1]!, p[i * 3 + 2]!);
177 + // Occlusion: closest approach of the camera→point line to the origin, restricted to the segment.
178 + const dx = v.x - cam.x;
179 + const dy = v.y - cam.y;
180 + const dz = v.z - cam.z;
181 + const len2 = dx * dx + dy * dy + dz * dz;
182 + const t = -(cam.x * dx + cam.y * dy + cam.z * dz) / len2;
183 + if (t > 0 && t < 1) {
184 + const cx = cam.x + dx * t;
185 + const cy = cam.y + dy * t;
186 + const cz = cam.z + dz * t;
187 + if (cx * cx + cy * cy + cz * cz < 1) continue;
188 + }
189 + v.project(camera);
190 + if (v.z > 1) continue;
191 + const x = ((v.x + 1) / 2) * size.width;
192 + const y = ((1 - v.y) / 2) * size.height;
193 + const d2 = (x - px) ** 2 + (y - py) ** 2;
194 + if (d2 > tol) continue;
195 + // Prefer close-to-cursor, then nearer to camera.
196 + const score = d2 + (len2 / camLen2) * 4;
197 + if (score < bestScore) {
198 + bestScore = score;
199 + bestD = d2;
200 + best = i;
201 + }
202 + }
203 + if (best >= 0 && bestD <= tol) onPick({ index: best, norad: data.norad[best] ?? 0 });
204 + else onPick(null);
205 + };
206 + el.addEventListener('pointerdown', down);
207 + el.addEventListener('pointerup', up);
208 + return () => {
209 + el.removeEventListener('pointerdown', down);
210 + el.removeEventListener('pointerup', up);
211 + };
212 + }, [gl, camera, size, data, flags, onPick]);
213 +
214 + const ringColor = useMemo(() => new THREE.Color(token('--accent')), []);
215 +
216 + return (
217 + <group>
218 + <points ref={pointsRef} geometry={geometry} material={material} frustumCulled={false} renderOrder={5} />
219 + <mesh ref={ringRef} visible={false} renderOrder={6}>
220 + <ringGeometry args={[0.7, 1, 40]} />
221 + <meshBasicMaterial color={ringColor} transparent opacity={0.95} depthTest={false} side={THREE.DoubleSide} />
222 + </mesh>
223 + </group>
224 + );
225 +}
added apps/web/src/components/globe/use-positions.ts +157 −0
@@ -0,0 +1,157 @@
1 +'use client';
2 +/**
3 + * Positions snapshot → GPU-ready buffers. Fetches `/orbit/positions` (SGP4 at t0 and t1 = t0 + step_s) every 30 s,
4 + * converts both endpoints to xyz on the compressed-altitude sphere so the render loop can lerp in Cartesian space
5 + * (no longitude wrap-around issues), and precomputes per-object class/mission/active flags for filtering.
6 + */
7 +import { useEffect, useRef, useState } from 'react';
8 +import { clientApi } from '@/lib/client-api';
9 +import type { PositionsSnapshot } from '@/lib/types';
10 +import { altToRadius, isLowPower, llaToXyz } from './geo';
11 +
12 +export const REFRESH_MS = 30_000;
13 +export const MOBILE_CAP = 6_000;
14 +
15 +export interface GlobeData {
16 + /** Number of rendered objects (≤ snapshot.count when capped). */
17 + n: number;
18 + /** Total objects in the snapshot (real count, before any device cap). */
19 + total: number;
20 + t0: number;
21 + t1: number;
22 + t0Iso: string;
23 + p0: Float32Array; // xyz at t0 (3n)
24 + p1: Float32Array; // xyz at t1 (3n)
25 + norad: Int32Array;
26 + cls: Uint8Array;
27 + mission: Uint8Array;
28 + active: Uint8Array;
29 + alt0: Float32Array;
30 + alt1: Float32Array;
31 + vel: Float32Array;
32 + legend: PositionsSnapshot['legend'];
33 + /** Counts over the full snapshot (not the capped subset). */
34 + counts: { cls: number[]; mission: number[]; active: number; inactive: number };
35 + capped: boolean;
36 + fetchedAt: number;
37 +}
38 +
39 +function priority(s: PositionsSnapshot, i: number): number {
40 + // Active first, then non-LEO (so the GEO/MEO rings stay legible on a capped device), then stations.
41 + const cls = s.legend.cls[s.cls[i] ?? 0] ?? 'LEO';
42 + const mission = s.legend.mission[s.mission[i] ?? 0] ?? '';
43 + return (s.active[i] ? 4 : 0) + (cls !== 'LEO' ? 2 : 0) + (mission === 'station' ? 1 : 0);
44 +}
45 +
46 +export function prepare(s: PositionsSnapshot, cap: number | null): GlobeData {
47 + const total = s.count;
48 + let order: number[] | null = null;
49 + if (cap !== null && total > cap) {
50 + order = Array.from({ length: total }, (_, i) => i);
51 + const pr = order.map((i) => priority(s, i));
52 + order.sort((a, b) => pr[b]! - pr[a]! || a - b);
53 + order.length = cap;
54 + }
55 + const n = order ? order.length : total;
56 + const p0 = new Float32Array(n * 3);
57 + const p1 = new Float32Array(n * 3);
58 + const norad = new Int32Array(n);
59 + const cls = new Uint8Array(n);
60 + const mission = new Uint8Array(n);
61 + const active = new Uint8Array(n);
62 + const alt0 = new Float32Array(n);
63 + const alt1 = new Float32Array(n);
64 + const vel = new Float32Array(n);
65 + for (let k = 0; k < n; k++) {
66 + const i = order ? order[k]! : k;
67 + const b = i * 6;
68 + const a0 = s.pos[b + 2] ?? 0;
69 + const a1 = s.pos[b + 5] ?? 0;
70 + llaToXyz(s.pos[b] ?? 0, s.pos[b + 1] ?? 0, altToRadius(a0), p0, k * 3);
71 + llaToXyz(s.pos[b + 3] ?? 0, s.pos[b + 4] ?? 0, altToRadius(a1), p1, k * 3);
72 + norad[k] = s.norad[i] ?? 0;
73 + cls[k] = s.cls[i] ?? 0;
74 + mission[k] = s.mission[i] ?? 0;
75 + active[k] = s.active[i] ?? 0;
76 + alt0[k] = a0;
77 + alt1[k] = a1;
78 + vel[k] = s.vel?.[i] ?? 0;
79 + }
80 + const counts = { cls: s.legend.cls.map(() => 0), mission: s.legend.mission.map(() => 0), active: 0, inactive: 0 };
81 + for (let i = 0; i < total; i++) {
82 + counts.cls[s.cls[i] ?? 0] = (counts.cls[s.cls[i] ?? 0] ?? 0) + 1;
83 + counts.mission[s.mission[i] ?? 0] = (counts.mission[s.mission[i] ?? 0] ?? 0) + 1;
84 + if (s.active[i]) counts.active++;
85 + else counts.inactive++;
86 + }
87 + return {
88 + n,
89 + total,
90 + t0: Date.parse(s.t0),
91 + t1: Date.parse(s.t1),
92 + t0Iso: s.t0,
93 + p0,
94 + p1,
95 + norad,
96 + cls,
97 + mission,
98 + active,
99 + alt0,
100 + alt1,
101 + vel,
102 + legend: s.legend,
103 + counts,
104 + capped: n < total,
105 + fetchedAt: Date.now(),
106 + };
107 +}
108 +
109 +export interface PositionsState {
110 + data: GlobeData | null;
111 + error: string | null;
112 + loading: boolean;
113 +}
114 +
115 +export function usePositions(enabled = true): PositionsState {
116 + const [state, setState] = useState<PositionsState>({ data: null, error: null, loading: true });
117 + const capRef = useRef<number | null>(null);
118 +
119 + useEffect(() => {
120 + if (!enabled) return;
121 + capRef.current = isLowPower() ? MOBILE_CAP : null;
122 + let ctrl: AbortController | null = null;
123 + let timer: ReturnType<typeof setTimeout> | null = null;
124 + let disposed = false;
125 +
126 + const tick = async () => {
127 + ctrl?.abort();
128 + ctrl = new AbortController();
129 + try {
130 + const res = await clientApi.positions(ctrl.signal);
131 + if (disposed) return;
132 + setState({ data: prepare(res.data, capRef.current), error: null, loading: false });
133 + } catch (e) {
134 + if (disposed || (e as Error).name === 'AbortError') return;
135 + setState((s) => ({ data: s.data, error: s.data ? null : 'Live positions unavailable', loading: false }));
136 + } finally {
137 + if (!disposed) timer = setTimeout(tick, REFRESH_MS);
138 + }
139 + };
140 + const onVis = () => {
141 + if (document.visibilityState === 'visible') {
142 + if (timer) clearTimeout(timer);
143 + void tick();
144 + }
145 + };
146 + void tick();
147 + document.addEventListener('visibilitychange', onVis);
148 + return () => {
149 + disposed = true;
150 + ctrl?.abort();
151 + if (timer) clearTimeout(timer);
152 + document.removeEventListener('visibilitychange', onVis);
153 + };
154 + }, [enabled]);
155 +
156 + return state;
157 +}
added apps/web/src/components/layout/mobile-tab-bar.tsx +75 −0
@@ -0,0 +1,75 @@
1 +'use client';
2 +import { Activity, BarChart3, Globe2, MoreHorizontal, Search } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { useEffect, useState } from 'react';
6 +import { cn } from '@/lib/cn';
7 +import { moreNav, primaryNav, routes } from '@/lib/site';
8 +import { useOpenSearch } from './search-context';
9 +
10 +/**
11 + * Native-feeling bottom tab bar (< md): Explore · Search · Events · Stats · More. Safe-area aware; the body reserves
12 + * `pb-[calc(58px+env(safe-area-inset-bottom))]` so it never covers the footer. Hidden on /explore (full-screen globe has its own controls).
13 + */
14 +export function MobileTabBar() {
15 + const pathname = usePathname();
16 + const openSearch = useOpenSearch();
17 + const [more, setMore] = useState(false);
18 + useEffect(() => setMore(false), [pathname]);
19 + const tabs = [
20 + { href: routes.explore(), label: 'Explore', Icon: Globe2, match: (p: string) => p === '/' || p.startsWith('/explore') || p.startsWith('/satellite') },
21 + { href: routes.events(), label: 'Events', Icon: Activity, match: (p: string) => p.startsWith('/events') },
22 + { href: routes.stats(), label: 'Stats', Icon: BarChart3, match: (p: string) => p.startsWith('/stats') || p.startsWith('/rankings') },
23 + ];
24 + return (
25 + <>
26 + {more && (
27 + <div className="fixed inset-0 z-[45] bg-black/50 md:hidden" onClick={() => setMore(false)}>
28 + <div className="panel absolute inset-x-2 bottom-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px)+8px)] max-h-[70vh] overflow-y-auto p-3" onClick={(e) => e.stopPropagation()}>
29 + <p className="eyebrow px-2 pb-2">Browse</p>
30 + <ul className="grid grid-cols-2 gap-1">
31 + {[...primaryNav, ...moreNav].map((n) => (
32 + <li key={n.href}>
33 + <Link href={n.href} className="block rounded-md px-3 py-3 text-[15px] text-ink-2 hover:bg-plane-2 hover:text-ink">{n.label}</Link>
34 + </li>
35 + ))}
36 + </ul>
37 + </div>
38 + </div>
39 + )}
40 + <nav aria-label="Primary (mobile)" className="safe-bottom fixed inset-x-0 bottom-0 z-[46] border-t border-rule bg-plane/95 backdrop-blur-md md:hidden">
41 + <ul className="grid h-[58px] grid-cols-5">
42 + {tabs.slice(0, 1).map(({ href, label, Icon, match }) => (
43 + <TabLink key={href} href={href} label={label} Icon={Icon} active={match(pathname)} />
44 + ))}
45 + <li className="min-w-0">
46 + <button type="button" onClick={openSearch} className="flex h-full w-full flex-col items-center justify-center gap-0.5 text-2xs text-ink-2">
47 + <Search size={21} aria-hidden strokeWidth={1.75} />
48 + <span className="truncate">Search</span>
49 + </button>
50 + </li>
51 + {tabs.slice(1).map(({ href, label, Icon, match }) => (
52 + <TabLink key={href} href={href} label={label} Icon={Icon} active={match(pathname)} />
53 + ))}
54 + <li className="min-w-0">
55 + <button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={cn('flex h-full w-full flex-col items-center justify-center gap-0.5 text-2xs', more ? 'text-accent' : 'text-ink-2')}>
56 + <MoreHorizontal size={21} aria-hidden strokeWidth={1.75} />
57 + <span className="truncate">More</span>
58 + </button>
59 + </li>
60 + </ul>
61 + </nav>
62 + </>
63 + );
64 +}
65 +
66 +function TabLink({ href, label, Icon, active }: { href: string; label: string; Icon: typeof Globe2; active: boolean }) {
67 + return (
68 + <li className="min-w-0">
69 + <Link href={href} aria-current={active ? 'page' : undefined} className={cn('flex h-full flex-col items-center justify-center gap-0.5 text-2xs', active ? 'text-accent' : 'text-ink-2')}>
70 + <Icon size={21} aria-hidden strokeWidth={active ? 2.25 : 1.75} />
71 + <span className="truncate">{label}</span>
72 + </Link>
73 + </li>
74 + );
75 +}
added apps/web/src/components/layout/search-context.tsx +36 −0
@@ -0,0 +1,36 @@
1 +'use client';
2 +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
3 +
4 +interface SearchCtx {
5 + open: boolean;
6 + setOpen: (v: boolean) => void;
7 +}
8 +const Ctx = createContext<SearchCtx>({ open: false, setOpen: () => undefined });
9 +
10 +export function SearchProvider({ children }: { children: ReactNode }) {
11 + const [open, setOpen] = useState(false);
12 + useEffect(() => {
13 + const onKey = (e: KeyboardEvent) => {
14 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
15 + e.preventDefault();
16 + setOpen((v) => !v);
17 + }
18 + if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement)?.tagName ?? '')) {
19 + e.preventDefault();
20 + setOpen(true);
21 + }
22 + };
23 + window.addEventListener('keydown', onKey);
24 + return () => window.removeEventListener('keydown', onKey);
25 + }, []);
26 + const value = useMemo(() => ({ open, setOpen }), [open]);
27 + return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
28 +}
29 +
30 +export function useSearch(): SearchCtx {
31 + return useContext(Ctx);
32 +}
33 +export function useOpenSearch(): () => void {
34 + const { setOpen } = useContext(Ctx);
35 + return useCallback(() => setOpen(true), [setOpen]);
36 +}
added apps/web/src/components/layout/search-dialog.tsx +169 −0
@@ -0,0 +1,169 @@
1 +'use client';
2 +import { ArrowRight, Globe2, Rocket, Satellite, Search, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useEffect, useRef, useState } from 'react';
6 +import { clientApi } from '@/lib/client-api';
7 +import { cn } from '@/lib/cn';
8 +import { routes } from '@/lib/site';
9 +import type { SearchResult } from '@/lib/types';
10 +import { useSearch } from './search-context';
11 +
12 +const EXAMPLES = ['ISS', 'Starlink', '25544', '1998-067A', 'SpaceX', 'Canada', 'GPS', 'weather satellites', 'GEO'];
13 +
14 +function Icon({ type }: { type: SearchResult['entity_type'] }) {
15 + const cls = 'size-4 shrink-0 text-ink-3';
16 + if (type === 'satellite') return <Satellite className={cls} aria-hidden />;
17 + if (type === 'launch' || type === 'launch_site') return <Rocket className={cls} aria-hidden />;
18 + return <Globe2 className={cls} aria-hidden />;
19 +}
20 +
21 +/** Command-palette style global search (⌘K or /). Same-origin API; keyboard navigable; mobile full-screen sheet. */
22 +export function SearchDialog() {
23 + const { open, setOpen } = useSearch();
24 + const router = useRouter();
25 + const [q, setQ] = useState('');
26 + const [results, setResults] = useState<SearchResult[]>([]);
27 + const [shortcuts, setShortcuts] = useState<{ label: string; href: string }[]>([]);
28 + const [active, setActive] = useState(0);
29 + const [loading, setLoading] = useState(false);
30 + const inputRef = useRef<HTMLInputElement>(null);
31 +
32 + useEffect(() => {
33 + if (open) {
34 + setTimeout(() => inputRef.current?.focus(), 20);
35 + document.body.style.overflow = 'hidden';
36 + } else {
37 + document.body.style.overflow = '';
38 + setQ('');
39 + setResults([]);
40 + setShortcuts([]);
41 + }
42 + return () => {
43 + document.body.style.overflow = '';
44 + };
45 + }, [open]);
46 +
47 + useEffect(() => {
48 + if (!open) return;
49 + const term = q.trim();
50 + if (term.length < 1) {
51 + setResults([]);
52 + setShortcuts([]);
53 + return;
54 + }
55 + const ctrl = new AbortController();
56 + const t = setTimeout(async () => {
57 + setLoading(true);
58 + try {
59 + const res = await clientApi.search(term, 12, ctrl.signal);
60 + setResults(res.data.results);
61 + setShortcuts(res.data.shortcuts);
62 + setActive(0);
63 + } catch {
64 + /* aborted or unavailable */
65 + } finally {
66 + setLoading(false);
67 + }
68 + }, 140);
69 + return () => {
70 + clearTimeout(t);
71 + ctrl.abort();
72 + };
73 + }, [q, open]);
74 +
75 + if (!open) return null;
76 + const items = [...shortcuts.map((s) => ({ href: s.href, title: s.label, subtitle: 'Filter', entity_type: 'filter' as const })), ...results];
77 +
78 + const onKey = (e: React.KeyboardEvent) => {
79 + if (e.key === 'ArrowDown') {
80 + e.preventDefault();
81 + setActive((a) => Math.min(a + 1, items.length - 1));
82 + } else if (e.key === 'ArrowUp') {
83 + e.preventDefault();
84 + setActive((a) => Math.max(a - 1, 0));
85 + } else if (e.key === 'Enter') {
86 + e.preventDefault();
87 + const it = items[active];
88 + if (it) {
89 + router.push(it.href);
90 + setOpen(false);
91 + } else if (q.trim()) {
92 + router.push(routes.search(q.trim()));
93 + setOpen(false);
94 + }
95 + } else if (e.key === 'Escape') {
96 + setOpen(false);
97 + }
98 + };
99 +
100 + return (
101 + <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/60 backdrop-blur-sm md:pt-[12vh]" role="dialog" aria-modal="true" aria-label="Search SatelliteIndex" onClick={() => setOpen(false)}>
102 + <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden md:h-auto md:max-h-[70vh] md:w-[640px] md:rounded-xl" onClick={(e) => e.stopPropagation()}>
103 + <div className="flex items-center gap-3 border-b border-rule px-4 py-3">
104 + <Search className="size-5 text-ink-3" aria-hidden />
105 + <input
106 + ref={inputRef}
107 + value={q}
108 + onChange={(e) => setQ(e.target.value)}
109 + onKeyDown={onKey}
110 + placeholder="Search satellites, NORAD, COSPAR, operators, countries…"
111 + className="min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"
112 + autoComplete="off"
113 + spellCheck={false}
114 + aria-label="Search"
115 + />
116 + <button type="button" onClick={() => setOpen(false)} className="flex size-9 items-center justify-center rounded-md text-ink-3 hover:bg-plane-2 hover:text-ink" aria-label="Close search">
117 + <X className="size-5" aria-hidden />
118 + </button>
119 + </div>
120 + <div className="scrollbar-thin flex-1 overflow-y-auto">
121 + {q.trim() === '' ? (
122 + <div className="px-4 py-4">
123 + <p className="eyebrow mb-2">Try</p>
124 + <div className="flex flex-wrap gap-2">
125 + {EXAMPLES.map((ex) => (
126 + <button key={ex} type="button" onClick={() => setQ(ex)} className="rounded-full border border-rule bg-plane-2 px-3 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
127 + {ex}
128 + </button>
129 + ))}
130 + </div>
131 + <p className="mt-6 text-xs text-ink-3">
132 + Press <kbd className="mono rounded border border-rule px-1">↵</kbd> for full results · <kbd className="mono rounded border border-rule px-1">esc</kbd> to close
133 + </p>
134 + </div>
135 + ) : items.length === 0 && !loading ? (
136 + <div className="px-4 py-8 text-center text-sm text-ink-3">No results for “{q}”. <Link href={routes.search(q)} onClick={() => setOpen(false)} className="link">Open full search</Link></div>
137 + ) : (
138 + <ul className="py-2">
139 + {items.map((it, i) => (
140 + <li key={`${it.entity_type}-${it.href}`}>
141 + <Link
142 + href={it.href}
143 + onClick={() => setOpen(false)}
144 + onMouseEnter={() => setActive(i)}
145 + className={cn('flex min-h-[48px] items-center gap-3 px-4 py-2.5', i === active ? 'bg-plane-3' : 'hover:bg-plane-2')}
146 + >
147 + {it.entity_type === 'filter' ? <ArrowRight className="size-4 shrink-0 text-accent" aria-hidden /> : <Icon type={it.entity_type} />}
148 + <span className="min-w-0 flex-1">
149 + <span className="block truncate text-[15px] text-ink">{it.title}</span>
150 + {it.subtitle && <span className="block truncate text-xs text-ink-3">{it.subtitle}</span>}
151 + </span>
152 + <span className="eyebrow hidden md:block">{it.entity_type.replace('_', ' ')}</span>
153 + </Link>
154 + </li>
155 + ))}
156 + {q.trim() && (
157 + <li>
158 + <Link href={routes.search(q.trim())} onClick={() => setOpen(false)} className="flex min-h-[44px] items-center gap-3 px-4 py-2 text-sm text-accent hover:bg-plane-2">
159 + <Search className="size-4" aria-hidden /> All results for “{q.trim()}”
160 + </Link>
161 + </li>
162 + )}
163 + </ul>
164 + )}
165 + </div>
166 + </div>
167 + </div>
168 + );
169 +}
added apps/web/src/components/layout/site-footer.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import Link from 'next/link';
2 +import { Wordmark } from '@/components/brand/logo';
3 +import { AUTHOR, CONTACT_EMAIL, routes } from '@/lib/site';
4 +
5 +const columns = [
6 + { title: 'Explore', links: [[routes.explore(), 'Live globe'], [routes.satellites(), 'Satellites'], [routes.constellations(), 'Constellations'], [routes.operators(), 'Operators'], [routes.countries(), 'Countries']] },
7 + { title: 'Activity', links: [[routes.launches(), 'Launches'], [routes.launchSites(), 'Launch sites'], [routes.debris(), 'Debris'], [routes.reentries(), 'Reentries'], [routes.events(), 'Events']] },
8 + { title: 'Data', links: [[routes.stats(), 'Statistics'], [routes.rankings(), 'Rankings'], [routes.sources(), 'Sources'], [routes.methodology(), 'Methodology'], [routes.status(), 'Status'], [routes.developers(), 'API']] },
9 + { title: 'SatelliteIndex', links: [[routes.about(), 'About'], [routes.privacy(), 'Privacy'], [routes.terms(), 'Terms'], [`mailto:${CONTACT_EMAIL}`, 'Contact']] },
10 +] as const;
11 +
12 +export function SiteFooter() {
13 + return (
14 + <footer className="mt-16 border-t border-rule bg-plane/40">
15 + <div className="container-x mx-auto max-w-[1600px] py-10">
16 + <div className="grid gap-8 md:grid-cols-[1.4fr_repeat(4,1fr)]">
17 + <div>
18 + <Wordmark />
19 + <p className="mt-3 max-w-xs text-sm leading-relaxed text-ink-3">The world&rsquo;s orbital infrastructure, mapped and indexed. Canonical, source-attributed data on everything in Earth orbit.</p>
20 + </div>
21 + {columns.map((c) => (
22 + <div key={c.title}>
23 + <p className="eyebrow mb-3">{c.title}</p>
24 + <ul className="space-y-1.5">
25 + {c.links.map(([href, label]) => (
26 + <li key={href}>
27 + <Link href={href} className="inline-block py-1 text-sm text-ink-2 hover:text-ink">{label}</Link>
28 + </li>
29 + ))}
30 + </ul>
31 + </div>
32 + ))}
33 + </div>
34 + <div className="mt-10 border-t border-rule pt-6 text-xs leading-relaxed text-ink-3">
35 + <p>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.</p>
36 + <p className="mt-2">
37 + 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.
38 + </p>
39 + <p className="mt-3">
40 + © {new Date().getUTCFullYear()} {AUTHOR} · <a href={`mailto:${CONTACT_EMAIL}`} className="hover:text-ink">{CONTACT_EMAIL}</a> · Hosted on{' '}
41 + <a href="https://www.maclustr.io" className="hover:text-ink" rel="noopener">MacLustr</a>
42 + </p>
43 + </div>
44 + </div>
45 + </footer>
46 + );
47 +}
added apps/web/src/components/layout/site-header.tsx +89 −0
@@ -0,0 +1,89 @@
1 +'use client';
2 +import { Menu, Search, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { useEffect, useState } from 'react';
6 +import { Wordmark } from '@/components/brand/logo';
7 +import { cn } from '@/lib/cn';
8 +import { moreNav, primaryNav, routes } from '@/lib/site';
9 +import { useOpenSearch } from './search-context';
10 +
11 +export function SiteHeader() {
12 + const pathname = usePathname();
13 + const openSearch = useOpenSearch();
14 + const [menu, setMenu] = useState(false);
15 + useEffect(() => setMenu(false), [pathname]);
16 + const isHome = pathname === '/';
17 + const isExplore = pathname === '/explore';
18 +
19 + return (
20 + <header className={cn('sticky top-0 z-40 border-b border-rule backdrop-blur-md', isHome || isExplore ? 'bg-space/70' : 'bg-space/85')}>
21 + <div className="container-x mx-auto flex h-[60px] max-w-[1600px] items-center gap-4">
22 + <Link href={routes.home()} className="flex h-11 items-center" aria-label="SatelliteIndex home">
23 + <Wordmark />
24 + </Link>
25 + <nav aria-label="Primary" className="ml-2 hidden items-center gap-0.5 lg:flex">
26 + {primaryNav.map((n) => {
27 + const active = pathname === n.href || (n.href !== '/' && pathname.startsWith(n.href + '/')) || (n.href === '/satellites' && pathname.startsWith('/satellite/')) || (n.href === '/constellations' && pathname.startsWith('/constellation/')) || (n.href === '/operators' && pathname.startsWith('/operator/')) || (n.href === '/countries' && pathname.startsWith('/country/')) || (n.href === '/launches' && pathname.startsWith('/launch/'));
28 + return (
29 + <Link key={n.href} href={n.href} aria-current={active ? 'page' : undefined} className={cn('rounded-md px-3 py-2 text-[13.5px] transition-colors', active ? 'bg-plane-2 text-ink' : 'text-ink-2 hover:bg-plane-2 hover:text-ink')}>
30 + {n.label}
31 + </Link>
32 + );
33 + })}
34 + </nav>
35 + <div className="ml-auto flex items-center gap-2">
36 + <button
37 + type="button"
38 + onClick={openSearch}
39 + className="hidden h-10 min-w-[260px] items-center gap-2 rounded-md border border-rule bg-plane-2/70 px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 md:flex"
40 + aria-label="Open search"
41 + >
42 + <Search className="size-4" aria-hidden />
43 + <span className="flex-1 text-left">Search satellites, operators, IDs…</span>
44 + <kbd className="mono rounded border border-rule px-1.5 py-0.5 text-[10px]">⌘K</kbd>
45 + </button>
46 + <button type="button" onClick={openSearch} className="flex size-11 items-center justify-center rounded-md text-ink-2 hover:bg-plane-2 hover:text-ink md:hidden" aria-label="Open search">
47 + <Search className="size-5" aria-hidden />
48 + </button>
49 + <button type="button" onClick={() => setMenu((m) => !m)} className="flex size-11 items-center justify-center rounded-md text-ink-2 hover:bg-plane-2 hover:text-ink lg:size-10" aria-label={menu ? 'Close menu' : 'Open menu'} aria-expanded={menu}>
50 + {menu ? <X className="size-5" aria-hidden /> : <Menu className="size-5" aria-hidden />}
51 + </button>
52 + </div>
53 + </div>
54 + {menu && (
55 + <div className="border-t border-rule bg-plane/95 backdrop-blur-md">
56 + <div className="container-x mx-auto grid max-w-[1600px] gap-6 py-5 md:grid-cols-3">
57 + <div>
58 + <p className="eyebrow mb-2">Explore</p>
59 + <ul className="grid grid-cols-2 gap-1 md:grid-cols-1">
60 + {primaryNav.map((n) => (
61 + <li key={n.href}>
62 + <Link href={n.href} className="block rounded-md px-2 py-2.5 text-[15px] text-ink-2 hover:bg-plane-2 hover:text-ink">{n.label}</Link>
63 + </li>
64 + ))}
65 + </ul>
66 + </div>
67 + <div>
68 + <p className="eyebrow mb-2">More</p>
69 + <ul className="grid grid-cols-2 gap-1 md:grid-cols-1">
70 + {moreNav.map((n) => (
71 + <li key={n.href}>
72 + <Link href={n.href} className="block rounded-md px-2 py-2.5 text-[15px] text-ink-2 hover:bg-plane-2 hover:text-ink">{n.label}</Link>
73 + </li>
74 + ))}
75 + </ul>
76 + </div>
77 + <div className="hidden md:block">
78 + <p className="eyebrow mb-2">About the index</p>
79 + <p className="text-sm leading-relaxed text-ink-2">
80 + SatelliteIndex aggregates public orbital, catalog and operator data into one canonical, source-attributed database. Positions are propagated with SGP4 from public element sets.
81 + </p>
82 + <Link href={routes.methodology()} className="mt-3 inline-block text-sm text-accent hover:underline">Read the methodology →</Link>
83 + </div>
84 + </div>
85 + </div>
86 + )}
87 + </header>
88 + );
89 +}
added apps/web/src/components/map/world-map.tsx +91 −0
@@ -0,0 +1,91 @@
1 +/**
2 + * Self-contained SVG world map (equirectangular, Natural Earth 110m via world-atlas). No external tiles.
3 + * Server-component friendly. Overlays: ground track (past/future), markers (launch sites, current position).
4 + */
5 +import { geoEquirectangular, geoGraticule10, geoPath } from 'd3-geo';
6 +import type { FeatureCollection, Geometry } from 'geojson';
7 +import { feature } from 'topojson-client';
8 +import type { Topology } from 'topojson-specification';
9 +import land110 from 'world-atlas/land-110m.json';
10 +import { cn } from '@/lib/cn';
11 +
12 +const topo = land110 as unknown as Topology;
13 +const land = feature(topo, topo.objects.land as never) as unknown as FeatureCollection<Geometry>;
14 +
15 +export interface MapPoint {
16 + lat: number;
17 + lon: number;
18 +}
19 +export interface MapMarker extends MapPoint {
20 + label?: string;
21 + color?: string;
22 + size?: number;
23 + href?: string;
24 + pulse?: boolean;
25 +}
26 +
27 +const W = 960;
28 +const H = 480;
29 +const projection = geoEquirectangular().scale(W / (2 * Math.PI)).translate([W / 2, H / 2]);
30 +const path = geoPath(projection);
31 +const landPath = path(land) ?? '';
32 +const gratPath = path(geoGraticule10()) ?? '';
33 +
34 +/** Split a lon/lat polyline where it crosses the antimeridian so no line is drawn across the map. */
35 +export function splitTrack(points: MapPoint[]): MapPoint[][] {
36 + const segs: MapPoint[][] = [];
37 + let cur: MapPoint[] = [];
38 + for (let i = 0; i < points.length; i++) {
39 + const p = points[i]!;
40 + const prev = points[i - 1];
41 + if (prev && Math.abs(p.lon - prev.lon) > 180) {
42 + segs.push(cur);
43 + cur = [];
44 + }
45 + cur.push(p);
46 + }
47 + if (cur.length) segs.push(cur);
48 + return segs.filter((s) => s.length > 1);
49 +}
50 +
51 +function toXY(p: MapPoint): [number, number] {
52 + return projection([p.lon, p.lat]) ?? [0, 0];
53 +}
54 +
55 +export function WorldMap({ className, tracks = [], markers = [], title = 'World map', children }: { className?: string; tracks?: { points: MapPoint[]; color?: string; dashed?: boolean; width?: number }[]; markers?: MapMarker[]; title?: string; children?: React.ReactNode }) {
56 + return (
57 + <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full select-none', className)} role="img" aria-label={title}>
58 + <title>{title}</title>
59 + <rect width={W} height={H} fill="var(--plane)" rx={8} />
60 + <path d={gratPath} fill="none" stroke="var(--rule)" strokeWidth={0.6} />
61 + <path d={landPath} fill="var(--plane-3)" stroke="var(--rule-strong)" strokeWidth={0.6} />
62 + {tracks.map((t, i) =>
63 + splitTrack(t.points).map((seg, j) => (
64 + <polyline key={`${i}-${j}`} points={seg.map((p) => toXY(p).join(',')).join(' ')} fill="none" stroke={t.color ?? 'var(--accent)'} strokeWidth={t.width ?? 1.6} strokeDasharray={t.dashed ? '4 4' : undefined} strokeLinejoin="round" strokeLinecap="round" opacity={0.95} />
65 + )),
66 + )}
67 + {markers.map((m, i) => {
68 + const [x, y] = toXY(m);
69 + const r = m.size ?? 4;
70 + const c = m.color ?? 'var(--accent)';
71 + const dot = (
72 + <g key={i}>
73 + {m.pulse && <circle cx={x} cy={y} r={r * 2.6} fill={c} opacity={0.18} />}
74 + <circle cx={x} cy={y} r={r} fill={c} stroke="var(--space)" strokeWidth={1.2} />
75 + {m.label && <text x={x + r + 4} y={y} dy="0.35em" fontSize={11} fill="var(--ink)" fontFamily="var(--font-mono)" stroke="var(--space)" strokeWidth={3} paintOrder="stroke">{m.label}</text>}
76 + </g>
77 + );
78 + return m.href ? (
79 + <a key={i} href={m.href}>
80 + {dot}
81 + </a>
82 + ) : (
83 + dot
84 + );
85 + })}
86 + {children}
87 + </svg>
88 + );
89 +}
90 +
91 +export const MAP_SIZE = { W, H, project: toXY };
added apps/web/src/components/satellite/altitude-chart.tsx +48 −0
@@ -0,0 +1,48 @@
1 +import { scaleLinear } from 'd3-scale';
2 +import { curveMonotoneX, line as d3line } from 'd3-shape';
3 +import { fmtDate, fmtInt, num } from '@/lib/format';
4 +import type { SatelliteHistory } from '@/lib/types';
5 +
6 +/**
7 + * Perigee / apogee over time (dual-line, SVG, server-safe). Local variant of the shared AreaChart which is single-series.
8 + * Renders only with ≥ 2 daily points — the caller shows an honest note otherwise.
9 + */
10 +export function AltitudeChart({ series, height = 180 }: { series: SatelliteHistory['altitude_series']; height?: number }) {
11 + const rows = series
12 + .map((r) => ({ day: r.day, perigee: num(r.perigee_km), apogee: num(r.apogee_km) }))
13 + .filter((r): r is { day: string; perigee: number; apogee: number } => r.perigee !== null && r.apogee !== null);
14 + if (rows.length < 2) return null;
15 + const W = 640;
16 + const H = height;
17 + const pad = { l: 44, r: 10, t: 10, b: 24 };
18 + const x = scaleLinear().domain([0, rows.length - 1]).range([pad.l, W - pad.r]);
19 + const lo = Math.min(...rows.map((r) => r.perigee));
20 + const hi = Math.max(...rows.map((r) => r.apogee));
21 + const padY = (hi - lo) * 0.15 || 2;
22 + const y = scaleLinear().domain([lo - padY, hi + padY]).nice().range([H - pad.b, pad.t]);
23 + const mk = (key: 'perigee' | 'apogee') => d3line<(typeof rows)[number]>().x((_, i) => x(i)).y((d) => y(d[key])).curve(curveMonotoneX)(rows) ?? '';
24 + const every = Math.max(1, Math.ceil(rows.length / 6));
25 + return (
26 + <div>
27 + <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label="Perigee and apogee altitude over time" preserveAspectRatio="none">
28 + <title>Perigee and apogee altitude over time</title>
29 + {y.ticks(4).map((t) => (
30 + <g key={t}>
31 + <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
32 + <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(t)}</text>
33 + </g>
34 + ))}
35 + <path d={mk('apogee')} fill="none" stroke="var(--series-2)" strokeWidth={1.6} />
36 + <path d={mk('perigee')} fill="none" stroke="var(--series-1)" strokeWidth={1.6} />
37 + {rows.map((r, i) => (i % every === 0 || i === rows.length - 1) && (
38 + <text key={r.day} x={x(i)} y={H - 6} textAnchor={i === rows.length - 1 ? 'end' : i === 0 ? 'start' : 'middle'} fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{r.day.slice(0, 10)}</text>
39 + ))}
40 + </svg>
41 + <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">
42 + <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0.5 w-4" style={{ background: 'var(--series-2)' }} /> Apogee (km)</li>
43 + <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0.5 w-4" style={{ background: 'var(--series-1)' }} /> Perigee (km)</li>
44 + <li className="text-ink-3">{rows.length} daily points · {fmtDate(rows[0]!.day)} → {fmtDate(rows[rows.length - 1]!.day)}</li>
45 + </ul>
46 + </div>
47 + );
48 +}
added apps/web/src/components/satellite/hero.tsx +129 −0
@@ -0,0 +1,129 @@
1 +import Link from 'next/link';
2 +import { FreshnessBadge, MissionLabel, OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';
3 +import { fmt1, fmtAgo, fmtDate, fmtDateTime, fmtDeg, fmtInt, fmtKm, fmtMinutes, num } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import type { SatelliteDetail } from '@/lib/types';
6 +import { DL, Derived, Row } from './primitives';
7 +
8 +export const OPS_STATUS: Record<string, string> = {
9 + '+': 'Operational',
10 + '-': 'Non-operational',
11 + P: 'Partially operational',
12 + B: 'Backup / standby',
13 + S: 'Spare',
14 + X: 'Extended mission',
15 + D: 'Decayed',
16 + '?': 'Unknown',
17 +};
18 +
19 +function KeyStat({ label, value, unit, hint }: { label: string; value: string; unit?: string; hint?: string }) {
20 + return (
21 + <div className="min-w-0 border-l border-rule pl-3">
22 + <p className="eyebrow">{label}</p>
23 + <p className="mono tnum mt-1 truncate text-lg font-medium text-ink md:text-2xl">
24 + {value}
25 + {unit && value !== '—' && <span className="ml-1 text-xs text-ink-3 md:text-sm">{unit}</span>}
26 + </p>
27 + {hint && <p className="mt-0.5 truncate text-2xs text-ink-3">{hint}</p>}
28 + </div>
29 + );
30 +}
31 +
32 +export function Hero({ d }: { d: SatelliteDetail }) {
33 + const os = d.orbital_state;
34 + const live = d.live && d.live.error == null ? d.live : null;
35 + const epochAge = live?.epoch_age_hours ?? (os ? (Date.now() - new Date(os.epoch).getTime()) / 3.6e6 : null);
36 + return (
37 + <header className="pb-6 pt-6 md:pb-8 md:pt-10">
38 + <p className="eyebrow mono">
39 + {d.object_type === 'PAYLOAD' || d.object_type === 'STATION' || d.object_type === 'CREWED' ? 'Satellite' : d.object_type === 'ROCKET_BODY' ? 'Rocket body' : d.object_type === 'DEBRIS' ? 'Debris object' : 'Catalogued object'}
40 + {d.norad_id !== null && <> · NORAD {d.norad_id}</>}
41 + {d.cospar_id && <> · COSPAR {d.cospar_id}</>}
42 + </p>
43 + <h1 className="display mt-2 break-words text-3xl md:text-5xl">{d.name}</h1>
44 +
45 + <div className="mt-4 flex flex-wrap items-center gap-2">
46 + <StatusBadge status={d.status} size="md" />
47 + <TypeBadge type={d.object_type} />
48 + <OrbitBadge orbitClass={d.orbit_class} />
49 + <span className="inline-flex items-center gap-1.5 text-xs text-ink-2">
50 + <MissionLabel mission={d.mission_type} /> <Derived />
51 + </span>
52 + </div>
53 +
54 + <ul className="mt-4 flex flex-wrap gap-x-5 gap-y-1.5 text-sm">
55 + <li>
56 + <span className="text-ink-3">Operator </span>
57 + {d.operator_slug ? <Link className="link" href={routes.operator(d.operator_slug)}>{d.operator_name}</Link> : <span className="text-ink-2">{d.owner_name ?? d.owner_code ?? '—'}</span>}
58 + </li>
59 + <li>
60 + <span className="text-ink-3">Constellation </span>
61 + {d.constellation_slug ? <Link className="link" href={routes.constellation(d.constellation_slug)}>{d.constellation_name}</Link> : <span className="text-ink-2">—</span>}
62 + </li>
63 + <li>
64 + <span className="text-ink-3">Country </span>
65 + {d.country_slug ? <Link className="link" href={routes.country(d.country_slug)}>{d.country_name}</Link> : <span className="text-ink-2">{d.owner_code === 'ISS' ? 'International' : '—'}</span>}
66 + </li>
67 + </ul>
68 +
69 + <dl className="mt-7 grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
70 + <KeyStat label="Altitude now" value={live ? fmt1(live.altitude_km) : '—'} unit="km" hint={live ? 'SGP4 propagation' : d.status === 'DECAYED' ? 'object decayed' : 'no element set'} />
71 + <KeyStat label="Velocity" value={live ? live.velocity_km_s.toFixed(2) : '—'} unit="km/s" />
72 + <KeyStat label="Inclination" value={os ? fmtDeg(os.inclination) : fmtDeg(d.inclination_deg)} />
73 + <KeyStat label="Period" value={fmtMinutes(os?.period_minutes ?? d.period_minutes)} hint={num(os?.period_minutes ?? d.period_minutes) !== null ? `${fmt1(os?.period_minutes ?? d.period_minutes)} min` : undefined} />
74 + <KeyStat label="Perigee" value={fmtInt(os?.perigee_km ?? d.perigee_km)} unit="km" />
75 + <KeyStat label="Apogee" value={fmtInt(os?.apogee_km ?? d.apogee_km)} unit="km" />
76 + </dl>
77 +
78 + <p className="mt-5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">
79 + <FreshnessBadge status={d.freshness.orbit.status} label={`orbit ${d.freshness.orbit.status}`} />
80 + {os ? (
81 + <span>
82 + Orbit updated {fmtAgo(os.updated_at)} · element epoch <span className="mono text-ink-2">{fmtDateTime(os.epoch)}</span>
83 + {epochAge !== null && <> ({fmt1(epochAge)} h old)</>}
84 + </span>
85 + ) : (
86 + <span>No orbital element set on file{d.decay_date ? ` — decayed ${fmtDate(d.decay_date)}` : ''}.</span>
87 + )}
88 + <span>· Metadata updated {fmtAgo(d.freshness.metadata.updated_at)}</span>
89 + </p>
90 + </header>
91 + );
92 +}
93 +
94 +/** Right-hand "terminal" panel: a compact readout of the object's state. Sticky on desktop. */
95 +export function TelemetryPanel({ d }: { d: SatelliteDetail }) {
96 + const os = d.orbital_state;
97 + const live = d.live && d.live.error == null ? d.live : null;
98 + return (
99 + <div className="panel p-4 md:p-5">
100 + <div className="flex items-center justify-between gap-3 border-b border-rule pb-3">
101 + <p className="eyebrow">Telemetry readout</p>
102 + <FreshnessBadge status={d.freshness.orbit.status} />
103 + </div>
104 + <DL>
105 + <Row label="Status" mono={false} value={<span className="inline-flex flex-wrap justify-end gap-1.5"><StatusBadge status={d.status} />{d.ops_status_code && <span className="text-xs text-ink-3" title="SATCAT operational status code">{OPS_STATUS[d.ops_status_code] ?? d.ops_status_code}</span>}</span>} />
106 + <Row label="Object type" mono={false} value={<TypeBadge type={d.object_type} />} />
107 + <Row label={<>Orbit class <Derived /></>} mono={false} value={<OrbitBadge orbitClass={d.orbit_class} />} />
108 + {live && <Row label="Altitude" value={fmtKm(live.altitude_km, 1)} />}
109 + {live && <Row label="Velocity" value={`${live.velocity_km_s.toFixed(3)} km/s`} />}
110 + <Row label="Perigee × apogee" value={os ? `${fmtInt(os.perigee_km)} × ${fmtInt(os.apogee_km)} km` : d.perigee_km !== null ? `${fmtInt(d.perigee_km)} × ${fmtInt(d.apogee_km)} km` : '—'} />
111 + <Row label="Inclination" value={fmtDeg(os?.inclination ?? d.inclination_deg)} />
112 + <Row label="Period" value={fmtMinutes(os?.period_minutes ?? d.period_minutes)} />
113 + <Row label="Eccentricity" value={os ? os.eccentricity.toFixed(6) : '—'} />
114 + <Row label="Element epoch" value={os ? fmtDateTime(os.epoch) : '—'} />
115 + {d.rcs_m2 !== null && <Row label="Radar cross-section" value={`${fmt1(d.rcs_m2)} m²`} />}
116 + <Row label="Launched" value={fmtDate(d.launch_date)} />
117 + {d.decay_date && <Row label="Decayed" value={fmtDate(d.decay_date)} />}
118 + <Row label="First indexed" value={fmtDate(d.first_seen_at)} />
119 + <Row label="Last seen in sources" value={fmtAgo(d.last_seen_at)} />
120 + </DL>
121 + <div className="mt-4 border-t border-rule pt-3 text-2xs text-ink-3">
122 + <p className="mono">NORAD {d.norad_id ?? '—'} · {d.cospar_id ?? 'no COSPAR id'}</p>
123 + <p className="mt-1">
124 + Sources: {Array.from(new Set(d.sources.map((s) => s.name))).join(', ') || 'none linked to this record'} · <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>
125 + </p>
126 + </div>
127 + </div>
128 + );
129 +}
added apps/web/src/components/satellite/live-map.tsx +127 −0
@@ -0,0 +1,127 @@
1 +'use client';
2 +import { useEffect, useRef, useState } from 'react';
3 +import { WorldMap, type MapPoint } from '@/components/map/world-map';
4 +import { clientApi } from '@/lib/client-api';
5 +import { fmtDateTime } from '@/lib/format';
6 +import type { LivePosition, TrackPoint } from '@/lib/types';
7 +
8 +const POLL_MS = 5000;
9 +
10 +interface Props {
11 + ident: string;
12 + name: string;
13 + initial: LivePosition | null;
14 + sourceEpoch: string | null;
15 +}
16 +
17 +function fmtCoord(v: number, pos: string, neg: string): string {
18 + return `${Math.abs(v).toFixed(3)}° ${v >= 0 ? pos : neg}`;
19 +}
20 +
21 +/**
22 + * Live ground track + current position. Server passes the first fix (from the detail payload) so the panel is
23 + * meaningful before hydration; the client then loads the ±track once and polls `/live` every 5 s while visible.
24 + */
25 +export function LiveMap({ ident, name, initial, sourceEpoch }: Props) {
26 + const [live, setLive] = useState<LivePosition | null>(initial);
27 + const [track, setTrack] = useState<TrackPoint[] | null>(null);
28 + const [trackError, setTrackError] = useState(false);
29 + const [tick, setTick] = useState<number | null>(null);
30 + const [failures, setFailures] = useState(0);
31 + const timer = useRef<ReturnType<typeof setInterval> | null>(null);
32 +
33 + useEffect(() => {
34 + const ctrl = new AbortController();
35 + clientApi
36 + .track(ident, ctrl.signal)
37 + .then((r) => setTrack(r.data.points))
38 + .catch(() => {
39 + if (!ctrl.signal.aborted) setTrackError(true);
40 + });
41 + return () => ctrl.abort();
42 + }, [ident]);
43 +
44 + useEffect(() => {
45 + let ctrl: AbortController | null = null;
46 + const poll = async () => {
47 + ctrl?.abort();
48 + ctrl = new AbortController();
49 + try {
50 + const r = await clientApi.live(ident, ctrl.signal);
51 + if (r.data && r.data.error == null && Number.isFinite(r.data.lat)) {
52 + setLive(r.data);
53 + setTick(Date.now());
54 + setFailures(0);
55 + } else setFailures((f) => f + 1);
56 + } catch {
57 + if (!ctrl?.signal.aborted) setFailures((f) => f + 1);
58 + }
59 + };
60 + const start = () => {
61 + if (timer.current) clearInterval(timer.current);
62 + void poll();
63 + timer.current = setInterval(poll, POLL_MS);
64 + };
65 + const stop = () => {
66 + if (timer.current) clearInterval(timer.current);
67 + timer.current = null;
68 + };
69 + const onVis = () => (document.hidden ? stop() : start());
70 + start();
71 + document.addEventListener('visibilitychange', onVis);
72 + return () => {
73 + stop();
74 + ctrl?.abort();
75 + document.removeEventListener('visibilitychange', onVis);
76 + };
77 + }, [ident]);
78 +
79 + const past: MapPoint[] = track ? track.filter((p) => !p.future).map((p) => ({ lat: p.lat, lon: p.lon })) : [];
80 + const future: MapPoint[] = track ? track.filter((p) => p.future).map((p) => ({ lat: p.lat, lon: p.lon })) : [];
81 + // join the two segments at the present so the line is continuous
82 + const lastPast = past[past.length - 1];
83 + if (lastPast && future.length) future.unshift(lastPast);
84 +
85 + const tracks = [
86 + { points: past, color: 'var(--ink-3)', dashed: true, width: 1.2 },
87 + { points: future, color: 'var(--accent)', width: 1.8 },
88 + ];
89 + const markers = live ? [{ lat: live.lat, lon: live.lon, color: 'var(--accent)', size: 5, pulse: true, label: name }] : [];
90 +
91 + return (
92 + <div>
93 + <div className="relative overflow-hidden rounded-lg border border-rule">
94 + <WorldMap tracks={tracks} markers={markers} title={`Ground track of ${name}`} />
95 + <div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2 rounded-md bg-space/70 px-2 py-1 text-2xs backdrop-blur">
96 + <span className={live && failures < 3 ? 'dot pulse text-active' : 'dot text-warn'} aria-hidden />
97 + <span className="mono text-ink-2">{failures >= 3 ? 'LIVE FEED INTERRUPTED' : 'LIVE · SGP4'}</span>
98 + </div>
99 + {trackError && <p className="absolute bottom-3 left-3 rounded-md bg-space/70 px-2 py-1 text-2xs text-warn backdrop-blur">Ground track unavailable</p>}
100 + {!track && !trackError && <p className="absolute bottom-3 left-3 rounded-md bg-space/70 px-2 py-1 text-2xs text-ink-3 backdrop-blur">Loading ground track…</p>}
101 + </div>
102 +
103 + <dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">
104 + <Tele label="Latitude" value={live ? fmtCoord(live.lat, 'N', 'S') : '—'} />
105 + <Tele label="Longitude" value={live ? fmtCoord(live.lon, 'E', 'W') : '—'} />
106 + <Tele label="Altitude" value={live ? `${live.altitude_km.toFixed(1)} km` : '—'} />
107 + <Tele label="Velocity" value={live ? `${live.velocity_km_s.toFixed(3)} km/s` : '—'} />
108 + </dl>
109 + <ul className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-2xs text-ink-3">
110 + <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0 w-5 border-t border-dashed border-ink-3" aria-hidden /> past 45 min</li>
111 + <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0 w-5 border-t-2 border-accent" aria-hidden /> next 90 min</li>
112 + <li className="mono">
113 + {tick ? `fix ${new Date(tick).toISOString().slice(11, 19)} UTC` : 'first fix from server render'} · element epoch {fmtDateTime(sourceEpoch ?? live?.source_epoch ?? null)}
114 + </li>
115 + </ul>
116 + </div>
117 + );
118 +}
119 +
120 +function Tele({ label, value }: { label: string; value: string }) {
121 + return (
122 + <div className="min-w-0">
123 + <dt className="eyebrow">{label}</dt>
124 + <dd className="mono tnum mt-0.5 truncate text-base text-ink md:text-lg">{value}</dd>
125 + </div>
126 + );
127 +}
added apps/web/src/components/satellite/primitives.tsx +80 −0
@@ -0,0 +1,80 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { routes } from '@/lib/site';
5 +
6 +/** Small building blocks shared by the satellite / launch pages (local variants — shared ui/* is read-only). */
7 +
8 +export function DL({ children, className }: { children: ReactNode; className?: string }) {
9 + return <dl className={cn('divide-y divide-[color:var(--rule)] text-sm', className)}>{children}</dl>;
10 +}
11 +
12 +export function Row({ label, value, mono = true, hint }: { label: ReactNode; value: ReactNode; mono?: boolean; hint?: ReactNode }) {
13 + return (
14 + <div className="grid grid-cols-[minmax(0,42%)_minmax(0,1fr)] items-baseline gap-3 py-2">
15 + <dt className="text-ink-3">
16 + {label}
17 + {hint && <span className="ml-1 text-2xs text-ink-3/70">{hint}</span>}
18 + </dt>
19 + <dd className={cn('min-w-0 break-words text-right text-ink', mono && 'mono tnum')}>{value}</dd>
20 + </div>
21 + );
22 +}
23 +
24 +export function Derived({ className }: { className?: string }) {
25 + return (
26 + <Link href={routes.methodology()} className={cn('inline-flex items-center rounded border border-dashed border-rule-strong px-1.5 py-px text-[10px] uppercase tracking-wider text-ink-3 hover:text-accent', className)} title="Derived by SatelliteIndex — see methodology">
27 + derived
28 + </Link>
29 + );
30 +}
31 +
32 +export function Note({ children, className }: { children: ReactNode; className?: string }) {
33 + return <p className={cn('rounded-md border border-dashed border-rule px-3 py-2.5 text-xs leading-relaxed text-ink-3', className)}>{children}</p>;
34 +}
35 +
36 +export function Chip({ href, children, active = false, count, className }: { href: string; children: ReactNode; active?: boolean; count?: ReactNode; className?: string }) {
37 + return (
38 + <Link
39 + href={href}
40 + className={cn(
41 + 'inline-flex min-h-9 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors',
42 + active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule bg-plane text-ink-2 hover:border-rule-strong hover:text-ink',
43 + className,
44 + )}
45 + >
46 + <span className="truncate">{children}</span>
47 + {count !== undefined && <span className="tnum text-[11px] text-ink-3">{count}</span>}
48 + </Link>
49 + );
50 +}
51 +
52 +export function Head({ eyebrow, title, action, children }: { eyebrow?: string; title: ReactNode; action?: { href: string; label: string }; children?: ReactNode }) {
53 + return (
54 + <div className="mb-4 flex items-end justify-between gap-4 border-b border-rule pb-3">
55 + <div>
56 + {eyebrow && <p className="eyebrow">{eyebrow}</p>}
57 + <h2 className="mt-0.5 text-lg font-semibold tracking-tight md:text-xl">{title}</h2>
58 + {children}
59 + </div>
60 + {action && (
61 + <Link href={action.href} className="shrink-0 py-1 text-sm text-accent hover:underline">
62 + {action.label} →
63 + </Link>
64 + )}
65 + </div>
66 + );
67 +}
68 +
69 +/** Wraps a page section; `id` allows deep links. */
70 +export function Block({ id, children, className }: { id: string; children: ReactNode; className?: string }) {
71 + return (
72 + <section id={id} className={cn('scroll-mt-20 py-6 md:py-8', className)}>
73 + {children}
74 + </section>
75 + );
76 +}
77 +
78 +export function Empty({ children }: { children: ReactNode }) {
79 + return <p className="text-sm text-ink-3">{children}</p>;
80 +}
added apps/web/src/components/satellite/view-beacon.tsx +11 −0
@@ -0,0 +1,11 @@
1 +'use client';
2 +import { useEffect } from 'react';
3 +import { clientApi } from '@/lib/client-api';
4 +
5 +/** Fire-and-forget page-view beacon (feeds the "trending" list). Once per mount, never blocks rendering. */
6 +export function ViewBeacon({ type, id }: { type: string; id: string }) {
7 + useEffect(() => {
8 + void clientApi.view(type, id);
9 + }, [type, id]);
10 + return null;
11 +}
added apps/web/src/components/stats/local-charts.tsx +78 −0
@@ -0,0 +1,78 @@
1 +/**
2 + * Local chart variants for the stats/debris/reentries pages (pure SVG, server-safe).
3 + * Same visual system as `@/components/charts/charts` — hairline axes, mono tick labels, token colours.
4 + */
5 +import { max as d3max } from 'd3-array';
6 +import { scaleLinear } from 'd3-scale';
7 +import { cn } from '@/lib/cn';
8 +import { fmtCompact } from '@/lib/format';
9 +
10 +export interface MultiBin {
11 + bin: number;
12 + values: number[];
13 +}
14 +
15 +/**
16 + * Grouped histogram: several series drawn side-by-side inside each bin (e.g. active payloads vs debris per 25 km).
17 + * `series` = labels + colours in the same order as `values`.
18 + */
19 +export function GroupedHistogram({ data, series, className, height = 170, title, unit = '', xTicks = 8 }: { data: MultiBin[]; series: { label: string; color: string }[]; className?: string; height?: number; title: string; unit?: string; xTicks?: number }) {
20 + if (!data.length || !series.length) {
21 + return (
22 + <div className={cn('flex items-center justify-center rounded-md border border-dashed border-rule text-xs text-ink-3', className)} style={{ height }} role="img" aria-label="Chart unavailable">
23 + Unavailable
24 + </div>
25 + );
26 + }
27 + const W = 640;
28 + const H = height;
29 + const pad = { l: 36, r: 8, t: 6, b: 22 };
30 + const bins = data.map((d) => d.bin);
31 + const step = bins.length > 1 ? Math.min(...bins.slice(1).map((b, i) => b - (bins[i] ?? 0)).filter((v) => v > 0)) || 1 : 1;
32 + const x = scaleLinear().domain([Math.min(...bins), Math.max(...bins) + step]).range([pad.l, W - pad.r]);
33 + const ymaxRaw = d3max(data, (d) => Math.max(...d.values)) || 1;
34 + const y = scaleLinear().domain([0, ymaxRaw]).nice().range([H - pad.b, pad.t]);
35 + const slot = Math.max(1, x(step) - x(0) - 1);
36 + const bw = Math.max(0.8, slot / series.length);
37 + return (
38 + <div className={className}>
39 + <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label={title} preserveAspectRatio="none">
40 + <title>{title}</title>
41 + {y.ticks(3).map((t) => (
42 + <g key={t}>
43 + <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
44 + <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>
45 + </g>
46 + ))}
47 + {data.map((d) =>
48 + d.values.map((v, si) => (
49 + <rect key={`${d.bin}-${si}`} x={x(d.bin) + si * bw} y={y(v)} width={bw} height={Math.max(0, H - pad.b - y(v))} fill={series[si]?.color ?? 'var(--series-1)'} opacity={0.9} />
50 + )),
51 + )}
52 + {x.ticks(xTicks).map((t) => (
53 + <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>
54 + ))}
55 + </svg>
56 + <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">
57 + {series.map((s) => (
58 + <li key={s.label} className="inline-flex items-center gap-1.5">
59 + <span className="inline-block size-2.5 rounded-sm" style={{ background: s.color }} /> {s.label}
60 + </li>
61 + ))}
62 + </ul>
63 + </div>
64 + );
65 +}
66 +
67 +/** Legend-only helper for charts that need an external key (e.g. two donuts sharing colours). */
68 +export function Legend({ items, className }: { items: { label: string; color: string }[]; className?: string }) {
69 + return (
70 + <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>
71 + {items.map((s) => (
72 + <li key={s.label} className="inline-flex items-center gap-1.5">
73 + <span className="inline-block size-2.5 rounded-sm" style={{ background: s.color }} /> {s.label}
74 + </li>
75 + ))}
76 + </ul>
77 + );
78 +}
added apps/web/src/components/stats/shared.tsx +140 −0
@@ -0,0 +1,140 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { fmtAgo } from '@/lib/format';
5 +import { routes } from '@/lib/site';
6 +import { FreshnessBadge } from '@/components/ui/badges';
7 +import type { StatsSnapshot } from '@/lib/types';
8 +
9 +/** "derived" tag with a link to the methodology page — mandatory next to every computed metric. */
10 +export function Derived({ className, what }: { className?: string; what?: string }) {
11 + return (
12 + <Link href={routes.methodology()} className={cn('mono inline-flex items-center gap-1 rounded border border-rule px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-ink-3 hover:border-rule-strong hover:text-accent', className)} title={what ? `${what} — derived by SatelliteIndex, see methodology` : 'Derived by SatelliteIndex, see methodology'}>
13 + derived
14 + </Link>
15 + );
16 +}
17 +
18 +/** Short explanatory note under a chart or table. `tone="warn"` renders a left rule for disclaimers. */
19 +export function Note({ children, tone = 'muted', className }: { children: ReactNode; tone?: 'muted' | 'warn'; className?: string }) {
20 + return (
21 + <p className={cn('text-xs leading-relaxed text-ink-3', tone === 'warn' && 'border-l-2 border-warn pl-3 text-ink-2', className)}>
22 + {children}
23 + </p>
24 + );
25 +}
26 +
27 +/** Prominent disclaimer block (debris / reentries pages). Text is shown verbatim from the API. */
28 +export function Disclaimer({ text, label = 'Disclaimer' }: { text: string; label?: string }) {
29 + return (
30 + <div className="flex gap-3 border-l-2 border-warn bg-warn-soft/40 py-3 pl-4 pr-4" role="note">
31 + <div className="min-w-0">
32 + <p className="eyebrow text-warn">{label}</p>
33 + <p className="mt-1 text-sm leading-relaxed text-ink">{text}</p>
34 + </div>
35 + </div>
36 + );
37 +}
38 +
39 +/** Responsive grid of `Stat` tiles separated by a hairline on top. */
40 +export function StatGrid({ children, cols = 4, className }: { children: ReactNode; cols?: 3 | 4 | 5; className?: string }) {
41 + const c = cols === 3 ? 'sm:grid-cols-3' : cols === 5 ? 'sm:grid-cols-3 lg:grid-cols-5' : 'sm:grid-cols-3 lg:grid-cols-4';
42 + return <div className={cn('grid grid-cols-2 gap-x-6 gap-y-6 border-t border-rule pt-5', c, className)}>{children}</div>;
43 +}
44 +
45 +/** Group title inside a section (e.g. "Catalogue", "Decays"). */
46 +export function GroupTitle({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
47 + return (
48 + <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
49 + <h3 className="text-sm font-semibold text-ink-2">{children}</h3>
50 + {hint && <span className="text-xs text-ink-3">{hint}</span>}
51 + </div>
52 + );
53 +}
54 +
55 +/** Chart block: small heading + optional note. No card chrome — hairline composition. */
56 +export function ChartBlock({ title, hint, children, note, derived, className }: { title: ReactNode; hint?: ReactNode; children: ReactNode; note?: ReactNode; derived?: boolean; className?: string }) {
57 + return (
58 + <div className={cn('min-w-0', className)}>
59 + <div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
60 + <h3 className="inline-flex items-center gap-2 text-sm font-semibold text-ink">
61 + {title}
62 + {derived && <Derived />}
63 + </h3>
64 + {hint && <span className="text-xs text-ink-3">{hint}</span>}
65 + </div>
66 + {children}
67 + {note && <div className="mt-2">{typeof note === 'string' ? <Note>{note}</Note> : note}</div>}
68 + </div>
69 + );
70 +}
71 +
72 +/** Two-column layout on desktop, stacked on mobile (DOM order = visual order). */
73 +export function TwoCol({ children, className }: { children: ReactNode; className?: string }) {
74 + return <div className={cn('grid gap-10 lg:grid-cols-2 lg:gap-12', className)}>{children}</div>;
75 +}
76 +
77 +/** Horizontal chip list (filters / metric selector). Scrolls on mobile without breaking layout. */
78 +export function Chips({ items, className, ariaLabel }: { items: { href: string; label: string; active: boolean; count?: string }[]; className?: string; ariaLabel: string }) {
79 + return (
80 + <nav aria-label={ariaLabel} className={cn('no-scrollbar -mx-4 overflow-x-auto px-4 md:mx-0 md:px-0', className)}>
81 + <ul className="flex w-max gap-2 md:w-auto md:flex-wrap">
82 + {items.map((it) => (
83 + <li key={it.href}>
84 + <Link
85 + href={it.href}
86 + aria-current={it.active ? 'page' : undefined}
87 + className={cn('inline-flex min-h-[44px] items-center gap-1.5 rounded-md border px-3 text-sm transition-colors md:min-h-[36px]', it.active ? 'border-accent/50 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}
88 + >
89 + {it.label}
90 + {it.count && <span className="mono text-[11px] text-ink-3">{it.count}</span>}
91 + </Link>
92 + </li>
93 + ))}
94 + </ul>
95 + </nav>
96 + );
97 +}
98 +
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>;
102 +}
103 +
104 +type Connector = StatsSnapshot['connectors'][number];
105 +
106 +export function connectorFreshness(c: Connector, now = Date.now()): 'fresh' | 'aging' | 'stale' | 'unavailable' | 'not_enabled' {
107 + if (!c.enabled) return 'not_enabled';
108 + if (!c.last_success_at) return 'unavailable';
109 + const t = new Date(c.last_success_at).getTime();
110 + if (Number.isNaN(t)) return 'unavailable';
111 + const age = (now - t) / 1000;
112 + if (age < 3 * c.interval_seconds) return 'fresh';
113 + if (age < 12 * c.interval_seconds) return 'aging';
114 + return 'stale';
115 +}
116 +
117 +/** Connector freshness strip — one line per connector. */
118 +export function ConnectorStrip({ connectors }: { connectors: Connector[] }) {
119 + if (!connectors.length) return null;
120 + const now = Date.now();
121 + return (
122 + <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
123 + {connectors.map((c) => {
124 + const f = connectorFreshness(c, now);
125 + return (
126 + <li key={c.name} className="min-w-0 border-t border-rule pt-3">
127 + <div className="flex items-center justify-between gap-2">
128 + <span className="mono truncate text-xs text-ink">{c.name}</span>
129 + <FreshnessBadge status={f} />
130 + </div>
131 + <p className="mt-1 text-xs text-ink-3">
132 + Last success {c.last_success_at ? fmtAgo(c.last_success_at, now) : 'unavailable'} · every {Math.round(c.interval_seconds / 60)} min
133 + {c.consecutive_failures > 0 && <span className="text-warn"> · {c.consecutive_failures} failure{c.consecutive_failures > 1 ? 's' : ''}</span>}
134 + </p>
135 + </li>
136 + );
137 + })}
138 + </ul>
139 + );
140 +}
added apps/web/src/components/stats/stats-composition.tsx +103 −0
@@ -0,0 +1,103 @@
1 +import Link from 'next/link';
2 +import { Donut, HBars, SERIES } from '@/components/charts/charts';
3 +import { fmtInt, num, titleCase } from '@/lib/format';
4 +import { MISSION_LABELS, OBJECT_TYPE_LABELS, ORBIT_CLASS_COLORS, routes } from '@/lib/site';
5 +import type { StatsSnapshot } from '@/lib/types';
6 +import { Legend } from './local-charts';
7 +import { ChartBlock, Note } from './shared';
8 +
9 +const ORBIT_ORDER = ['LEO', 'MEO', 'GEO', 'HEO', 'OTHER', 'UNKNOWN'];
10 +
11 +/** Composition of the catalogue: orbit class (two donuts), object type, mission type, constellation shares. */
12 +export function StatsComposition({ snapshot }: { snapshot: StatsSnapshot }) {
13 + const byOrbit = [...snapshot.by_orbit_class].sort((a, b) => ORBIT_ORDER.indexOf(a.orbit_class) - ORBIT_ORDER.indexOf(b.orbit_class));
14 + const activeDonut = byOrbit.map((r) => ({ label: r.orbit_class === 'UNKNOWN' ? 'Unclassified' : r.orbit_class, value: num(r.active) ?? 0, color: ORBIT_CLASS_COLORS[r.orbit_class] ?? 'var(--other)' })).filter((d) => d.value > 0);
15 + const onOrbitDonut = byOrbit.map((r) => ({ label: r.orbit_class === 'UNKNOWN' ? 'Unclassified' : r.orbit_class, value: num(r.on_orbit) ?? 0, color: ORBIT_CLASS_COLORS[r.orbit_class] ?? 'var(--other)' })).filter((d) => d.value > 0);
16 +
17 + const byType = [...snapshot.by_object_type].sort((a, b) => (num(b.on_orbit) ?? 0) - (num(a.on_orbit) ?? 0));
18 + const typeBars = byType.map((r, i) => ({ label: OBJECT_TYPE_LABELS[r.object_type] ?? titleCase(r.object_type), value: num(r.on_orbit) ?? 0, color: SERIES[i % SERIES.length] }));
19 +
20 + const byMission = [...snapshot.by_mission_type].sort((a, b) => (num(b.active) ?? 0) - (num(a.active) ?? 0));
21 + const missionBars = byMission.map((r) => ({ label: MISSION_LABELS[r.mission_type] ?? titleCase(r.mission_type), value: num(r.active) ?? 0, color: 'var(--series-3)' }));
22 +
23 + const totalActive = num(snapshot.global.active_satellites) ?? 0;
24 + const constellations = [...snapshot.top_constellations].sort((a, b) => (num(b.active) ?? 0) - (num(a.active) ?? 0)).slice(0, 12);
25 + const constellationActive = constellations.reduce((s, c) => s + (num(c.active) ?? 0), 0);
26 +
27 + return (
28 + <div className="space-y-12">
29 + <div className="grid gap-10 lg:grid-cols-2 lg:gap-12">
30 + <ChartBlock title="Active satellites by orbit class" hint={`${fmtInt(snapshot.global.active_satellites)} active`} derived>
31 + <Donut data={activeDonut} title="Active satellites by orbit class" size={150} />
32 + </ChartBlock>
33 + <ChartBlock title="Objects on orbit by orbit class" hint={`${fmtInt(snapshot.global.objects_on_orbit)} objects`} derived>
34 + <Donut data={onOrbitDonut} title="Objects on orbit by orbit class" size={150} />
35 + </ChartBlock>
36 + </div>
37 + <Legend items={ORBIT_ORDER.filter((c) => byOrbit.some((r) => r.orbit_class === c)).map((c) => ({ label: c === 'UNKNOWN' ? 'Unclassified (no element set)' : c, color: ORBIT_CLASS_COLORS[c] ?? 'var(--other)' }))} className="-mt-6" />
38 +
39 + <div className="grid gap-10 lg:grid-cols-2 lg:gap-12">
40 + <ChartBlock title="Objects on orbit by type" hint="SATCAT object type">
41 + <HBars data={typeBars} />
42 + <table className="data-table mt-5 text-sm">
43 + <thead>
44 + <tr>
45 + <th>Type</th>
46 + <th className="num">On orbit</th>
47 + <th className="num">Catalogued</th>
48 + <th className="num">Decayed</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {byType.map((r) => {
53 + const total = num(r.total) ?? 0;
54 + const on = num(r.on_orbit) ?? 0;
55 + return (
56 + <tr key={r.object_type}>
57 + <td>{OBJECT_TYPE_LABELS[r.object_type] ?? titleCase(r.object_type)}</td>
58 + <td className="num tnum">{fmtInt(on)}</td>
59 + <td className="num tnum">{fmtInt(total)}</td>
60 + <td className="num tnum text-ink-3">{fmtInt(total - on)}</td>
61 + </tr>
62 + );
63 + })}
64 + </tbody>
65 + </table>
66 + </ChartBlock>
67 + <ChartBlock title="Active satellites by mission type" hint="Active payloads" derived note="Mission type is classified by SatelliteIndex from names, constellation registry and CelesTrak groups.">
68 + <HBars data={missionBars} />
69 + </ChartBlock>
70 + </div>
71 +
72 + <ChartBlock title="Constellation shares" hint={`Top ${constellations.length} · ${totalActive ? ((constellationActive / totalActive) * 100).toFixed(1) : '—'}% of active satellites`} derived>
73 + <ul className="space-y-1.5">
74 + {constellations.map((c, i) => {
75 + const active = num(c.active) ?? 0;
76 + const pct = totalActive ? (active / totalActive) * 100 : 0;
77 + const maxActive = num(constellations[0]?.active) ?? 1;
78 + return (
79 + <li key={c.id} className="text-sm">
80 + <div className="flex items-baseline justify-between gap-3">
81 + <Link href={routes.constellation(c.slug)} className="link min-w-0 truncate">
82 + <span className="mono mr-2 text-[11px] text-ink-3">{String(i + 1).padStart(2, '0')}</span>
83 + {c.name}
84 + </Link>
85 + <span className="tnum shrink-0 text-xs text-ink-2">
86 + {fmtInt(active)} <span className="text-ink-3">· {pct < 0.1 ? '<0.1' : pct.toFixed(1)}%</span>
87 + </span>
88 + </div>
89 + <div className="mt-1 h-[6px] w-full overflow-hidden rounded-full bg-plane-2">
90 + <div className="h-full rounded-full" style={{ width: `${Math.max(1, (active / maxActive) * 100)}%`, background: ORBIT_CLASS_COLORS[c.orbit_class ?? ''] ?? 'var(--series-1)' }} />
91 + </div>
92 + </li>
93 + );
94 + })}
95 + </ul>
96 + <Note className="mt-3">
97 + Share = active satellites in the constellation ÷ all active satellites. Bar colour = orbit class. Constellation membership is derived from the SatelliteIndex registry —{' '}
98 + <Link href={routes.rankings('constellations')} className="text-accent hover:underline">full ranking</Link>.
99 + </Note>
100 + </ChartBlock>
101 + </div>
102 + );
103 +}
added apps/web/src/components/stats/stats-headline.tsx +49 −0
@@ -0,0 +1,49 @@
1 +import { Stat } from '@/components/ui/section';
2 +import { fmtAgo, fmtInt } from '@/lib/format';
3 +import type { StatsSnapshot } from '@/lib/types';
4 +import { GroupTitle, StatGrid } from './shared';
5 +
6 +/** Headline tiles of the global statistics dashboard, grouped by theme. All values come straight from the snapshot. */
7 +export function StatsHeadline({ snapshot }: { snapshot: StatsSnapshot }) {
8 + const g = snapshot.global;
9 + const l = snapshot.launches;
10 + return (
11 + <div className="space-y-10">
12 + <div>
13 + <GroupTitle hint={g.latest_epoch ? `Latest element set epoch ${fmtAgo(g.latest_epoch)}` : undefined}>Catalogue</GroupTitle>
14 + <StatGrid cols={4}>
15 + <Stat label="Active satellites" value={fmtInt(g.active_satellites)} accent hint="Operational payloads" />
16 + <Stat label="Objects on orbit" value={fmtInt(g.objects_on_orbit)} hint="All tracked objects" />
17 + <Stat label="Objects catalogued" value={fmtInt(g.objects_catalogued)} hint="Since 1957, incl. decayed" />
18 + <Stat label="With element sets" value={fmtInt(g.with_elements)} hint="Objects with current GP data" />
19 + <Stat label="Payloads on orbit" value={fmtInt(g.payloads_on_orbit)} hint={`${fmtInt(g.payloads_total)} catalogued`} />
20 + <Stat label="Debris on orbit" value={fmtInt(g.debris_on_orbit)} hint="Catalogued fragments" />
21 + <Stat label="Rocket bodies" value={fmtInt(g.rocket_bodies_on_orbit)} hint="On orbit" />
22 + <Stat label="Decayed objects" value={fmtInt(g.decayed_objects)} hint={`${fmtInt(g.decayed_last_30d)} last 30 d · ${fmtInt(g.decayed_last_365d)} last 365 d`} />
23 + </StatGrid>
24 + </div>
25 +
26 + <div>
27 + <GroupTitle hint="Payloads by launch date">Launch activity</GroupTitle>
28 + <StatGrid cols={4}>
29 + <Stat label="Launched 30 d" value={fmtInt(g.payloads_launched_30d)} hint="Payloads" />
30 + <Stat label="Launched YTD" value={fmtInt(g.payloads_launched_ytd)} hint="Payloads" />
31 + <Stat label="Launched 365 d" value={fmtInt(g.payloads_launched_365d)} hint="Payloads" />
32 + <Stat label="Launches 30 d" value={fmtInt(l.last_30d)} hint="Orbital launches" />
33 + <Stat label="Launches YTD" value={fmtInt(l.ytd)} hint="Orbital launches" />
34 + <Stat label="Launches 365 d" value={fmtInt(l.last_365d)} hint="Orbital launches" />
35 + <Stat label="Launches total" value={fmtInt(l.total)} hint="Since 1957" />
36 + </StatGrid>
37 + </div>
38 +
39 + <div>
40 + <GroupTitle hint="Entities with at least one active payload">Ecosystem</GroupTitle>
41 + <StatGrid cols={3}>
42 + <Stat label="Active operators" value={fmtInt(g.active_operators)} />
43 + <Stat label="Active countries" value={fmtInt(g.active_countries)} />
44 + <Stat label="Active constellations" value={fmtInt(g.active_constellations)} />
45 + </StatGrid>
46 + </div>
47 + </div>
48 + );
49 +}
added apps/web/src/components/stats/stats-history.tsx +92 −0
@@ -0,0 +1,92 @@
1 +import Link from 'next/link';
2 +import { Bars, StackedBars } from '@/components/charts/charts';
3 +import { fmtInt, num } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import type { StatsSnapshot } from '@/lib/types';
6 +import { ChartBlock, Derived, Note, TwoCol } from './shared';
7 +
8 +/** Full-history charts (since 1957) + derived growth-rate table. */
9 +export function StatsHistory({ snapshot }: { snapshot: StatsSnapshot }) {
10 + const launches = [...snapshot.launches_by_year].sort((a, b) => a.year - b.year);
11 + const payloads = [...snapshot.payloads_by_launch_year].sort((a, b) => a.year - b.year);
12 + const decays = [...snapshot.decays_by_year].sort((a, b) => a.year - b.year);
13 + const first = launches[0]?.year;
14 + const last = launches[launches.length - 1]?.year;
15 + const span = first && last ? `${first}–${last}` : undefined;
16 +
17 + const launchSeries = launches.map((r) => ({ x: r.year, y: num(r.launches) ?? 0 }));
18 + const payloadStack = payloads.map((r) => {
19 + const p = num(r.payloads) ?? 0;
20 + const a = num(r.still_active) ?? 0;
21 + return { x: r.year, still_active: a, retired: Math.max(0, p - a) };
22 + });
23 + const decaySeries = decays.map((r) => ({ x: r.year, y: num(r.decayed) ?? 0 }));
24 +
25 + // Derived: year-over-year change of payloads launched (last 10 complete + current year).
26 + const growth = payloads
27 + .map((r, i) => {
28 + const cur = num(r.payloads) ?? 0;
29 + const prev = i > 0 ? (num(payloads[i - 1]?.payloads) ?? 0) : null;
30 + const pct = prev && prev > 0 ? ((cur - prev) / prev) * 100 : null;
31 + return { year: r.year, payloads: cur, prev, pct, active: num(r.still_active) ?? 0 };
32 + })
33 + .slice(-10)
34 + .reverse();
35 + const currentYear = new Date().getUTCFullYear();
36 +
37 + return (
38 + <div className="space-y-12">
39 + <ChartBlock title="Orbital launches per year" hint={span}>
40 + <Bars data={launchSeries} title="Orbital launches per year" height={190} xTicks={7} color="var(--series-1)" highlightLast />
41 + <Note className="mt-2">Launches with at least one catalogued object (CelesTrak SATCAT, grouped by COSPAR launch id). The last bar is the current, incomplete year.</Note>
42 + </ChartBlock>
43 +
44 + <TwoCol>
45 + <ChartBlock title="Payloads by launch year" hint="Still active vs retired">
46 + <StackedBars data={payloadStack} keys={['still_active', 'retired']} labels={{ still_active: 'Still active', retired: 'Retired / decayed' }} title="Payloads by launch year, still active vs retired" height={200} xTicks={7} />
47 + <Note className="mt-2">Retired = payloads catalogued for that launch year − payloads still active today (derived from the two snapshot series).</Note>
48 + </ChartBlock>
49 + <ChartBlock title="Decays per year" hint="All object types">
50 + <Bars data={decaySeries} title="Objects decayed per year" height={200} xTicks={7} color="var(--series-4)" highlightLast />
51 + <Note className="mt-2">
52 + Objects with a published decay date in the SATCAT. Debris-only breakdown on <Link href={routes.debris()} className="text-accent hover:underline">/debris</Link>; recent reentries on{' '}
53 + <Link href={routes.reentries()} className="text-accent hover:underline">/reentries</Link>.
54 + </Note>
55 + </ChartBlock>
56 + </TwoCol>
57 +
58 + <ChartBlock title="Satellite growth rate" hint="Last 10 years" derived>
59 + <table className="data-table stack text-sm">
60 + <thead>
61 + <tr>
62 + <th>Year</th>
63 + <th className="num">Payloads launched</th>
64 + <th className="num">Previous year</th>
65 + <th className="num">YoY change</th>
66 + <th className="num">Still active</th>
67 + </tr>
68 + </thead>
69 + <tbody>
70 + {growth.map((g) => (
71 + <tr key={g.year}>
72 + <td className="primary mono" data-label="Year">
73 + {g.year}
74 + {g.year === currentYear && <span className="ml-2 text-[11px] text-ink-3">year to date</span>}
75 + </td>
76 + <td className="num tnum" data-label="Payloads launched">{fmtInt(g.payloads)}</td>
77 + <td className="num tnum text-ink-3" data-label="Previous year">{g.prev === null ? '—' : fmtInt(g.prev)}</td>
78 + <td className="num tnum" data-label="YoY change">
79 + {g.pct === null ? <span className="text-ink-3">—</span> : <span className={g.pct >= 0 ? 'text-active' : 'text-danger'}>{g.pct >= 0 ? '+' : ''}{g.pct.toFixed(1)}%</span>}
80 + </td>
81 + <td className="num tnum" data-label="Still active">{fmtInt(g.active)}</td>
82 + </tr>
83 + ))}
84 + </tbody>
85 + </table>
86 + <Note className="mt-3">
87 + <Derived className="mr-2" /> YoY change = (payloads launched in year − payloads launched the previous year) ÷ previous year, computed from the snapshot series. The current year is partial, so its change is not comparable.
88 + </Note>
89 + </ChartBlock>
90 + </div>
91 + );
92 +}
added apps/web/src/components/ui/badges.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import { cn } from '@/lib/cn';
2 +import { MISSION_LABELS, OBJECT_TYPE_LABELS, ORBIT_CLASS_COLORS, STATUS_COLORS } from '@/lib/site';
3 +
4 +export function StatusBadge({ status, className, size = 'sm' }: { status: string | null | undefined; className?: string; size?: 'sm' | 'md' }) {
5 + const s = status ?? 'UNKNOWN';
6 + const color = STATUS_COLORS[s] ?? 'var(--inactive)';
7 + return (
8 + <span className={cn('inline-flex items-center gap-1.5 rounded-full border font-medium', size === 'sm' ? 'px-2 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)` }}>
9 + <span className={cn('dot', s === 'ACTIVE' && 'pulse')} aria-hidden />
10 + {s.charAt(0) + s.slice(1).toLowerCase()}
11 + </span>
12 + );
13 +}
14 +
15 +export function OrbitBadge({ orbitClass, className }: { orbitClass: string | null | undefined; className?: string }) {
16 + const c = orbitClass ?? 'UNKNOWN';
17 + const color = ORBIT_CLASS_COLORS[c] ?? 'var(--other)';
18 + return (
19 + <span className={cn('mono inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium', className)} style={{ color, background: `color-mix(in oklab, ${color} 12%, transparent)` }}>
20 + {c === 'UNKNOWN' ? '—' : c}
21 + </span>
22 + );
23 +}
24 +
25 +export function TypeBadge({ type, className }: { type: string | null | undefined; className?: string }) {
26 + return <span className={cn('inline-flex items-center rounded border border-rule px-1.5 py-0.5 text-[11px] text-ink-2', className)}>{OBJECT_TYPE_LABELS[type ?? ''] ?? type ?? '—'}</span>;
27 +}
28 +
29 +export function MissionLabel({ mission }: { mission: string | null | undefined }) {
30 + return <>{MISSION_LABELS[mission ?? 'unknown'] ?? mission ?? 'Unknown'}</>;
31 +}
32 +
33 +export function FreshnessBadge({ status, label }: { status: 'fresh' | 'aging' | 'stale' | 'unavailable' | 'not_enabled' | string; label?: string }) {
34 + const color = status === 'fresh' ? 'var(--active)' : status === 'aging' ? 'var(--warn)' : status === 'stale' ? 'var(--danger)' : 'var(--inactive)';
35 + return (
36 + <span className="inline-flex items-center gap-1.5 text-xs" style={{ color }}>
37 + <span className="dot" aria-hidden /> {label ?? status.replace('_', ' ')}
38 + </span>
39 + );
40 +}
added apps/web/src/components/ui/pagination.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/cn';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +/** URL-driven pagination (server component). `makeHref(page)` builds the link preserving current filters. */
6 +export function Pagination({ page, pages, total, pageSize, makeHref, className }: { page: number; pages: number; total: number; pageSize: number; makeHref: (p: number) => string; className?: string }) {
7 + if (pages <= 1) return <p className={cn('text-xs text-ink-3', className)}>{fmtInt(total)} results</p>;
8 + const from = (page - 1) * pageSize + 1;
9 + const to = Math.min(total, page * pageSize);
10 + const btn = 'inline-flex h-10 min-w-10 items-center justify-center rounded-md border border-rule px-3 text-sm text-ink-2 hover:bg-plane-2 hover:text-ink';
11 + return (
12 + <nav className={cn('flex flex-wrap items-center justify-between gap-3', className)} aria-label="Pagination">
13 + <p className="tnum text-xs text-ink-3">
14 + {fmtInt(from)}–{fmtInt(to)} of {fmtInt(total)}
15 + </p>
16 + <div className="flex items-center gap-1.5">
17 + {page > 1 ? <Link href={makeHref(page - 1)} className={btn} rel="prev">‹ Prev</Link> : <span className={cn(btn, 'opacity-40')}>‹ Prev</span>}
18 + <span className="mono px-2 text-xs text-ink-3">{page} / {fmtInt(pages)}</span>
19 + {page < pages ? <Link href={makeHref(page + 1)} className={btn} rel="next">Next ›</Link> : <span className={cn(btn, 'opacity-40')}>Next ›</span>}
20 + </div>
21 + </nav>
22 + );
23 +}
added apps/web/src/components/ui/section.tsx +51 −0
@@ -0,0 +1,51 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +
5 +/** Page section with eyebrow + title + optional action. Spatial composition — not everything in a card. */
6 +export function Section({ eyebrow, title, action, children, className, id }: { eyebrow?: string; title?: ReactNode; action?: { href: string; label: string }; children: ReactNode; className?: string; id?: string }) {
7 + return (
8 + <section id={id} className={cn('py-8 md:py-12', className)}>
9 + {(eyebrow || title) && (
10 + <div className="mb-5 flex items-end justify-between gap-4">
11 + <div>
12 + {eyebrow && <p className="eyebrow">{eyebrow}</p>}
13 + {title && <h2 className="mt-1 text-xl font-semibold tracking-tight md:text-2xl">{title}</h2>}
14 + </div>
15 + {action && (
16 + <Link href={action.href} className="shrink-0 py-1 text-sm text-accent hover:underline">
17 + {action.label} →
18 + </Link>
19 + )}
20 + </div>
21 + )}
22 + {children}
23 + </section>
24 + );
25 +}
26 +
27 +export function PageHeader({ eyebrow, title, lede, children, className }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; children?: ReactNode; className?: string }) {
28 + return (
29 + <div className={cn('pb-6 pt-8 md:pb-8 md:pt-12', className)}>
30 + {eyebrow && <p className="eyebrow">{eyebrow}</p>}
31 + <h1 className="display mt-2 text-3xl md:text-5xl">{title}</h1>
32 + {lede && <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">{lede}</p>}
33 + {children}
34 + </div>
35 + );
36 +}
37 +
38 +/** Stat tile: big tabular number + label + optional hint. */
39 +export function Stat({ label, value, hint, className, accent = false }: { label: string; value: ReactNode; hint?: ReactNode; className?: string; accent?: boolean }) {
40 + return (
41 + <div className={cn('min-w-0', className)}>
42 + <p className="eyebrow">{label}</p>
43 + <p className={cn('tnum mt-1 text-2xl font-semibold tracking-tight md:text-3xl', accent && 'text-accent')}>{value}</p>
44 + {hint && <p className="mt-0.5 text-xs text-ink-3">{hint}</p>}
45 + </div>
46 + );
47 +}
48 +
49 +export function Container({ children, className, wide = false }: { children: ReactNode; className?: string; wide?: boolean }) {
50 + return <div className={cn('container-x mx-auto w-full', wide ? 'max-w-[1600px]' : 'max-w-[1280px]', className)}>{children}</div>;
51 +}
added apps/web/src/components/ui/unavailable.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import { cn } from '@/lib/cn';
2 +
3 +/** Honest empty state: when a source/API is unavailable we say so instead of inventing numbers (CLAUDE.md §151). */
4 +export function Unavailable({ what = 'Data', className, compact = false }: { what?: string; className?: string; compact?: boolean }) {
5 + return (
6 + <div className={cn('rounded-lg border border-dashed border-rule-strong text-ink-3', compact ? 'px-3 py-2 text-xs' : 'px-5 py-8 text-center text-sm', className)} role="status">
7 + {what} unavailable
8 + </div>
9 + );
10 +}
added apps/web/src/lib/api.ts +162 −0
@@ -0,0 +1,162 @@
1 +import 'server-only';
2 +import type {
3 + ConstellationDetail,
4 + ConstellationRow,
5 + CountryDetail,
6 + CountryRow,
7 + DebrisPayload,
8 + DensityPayload,
9 + Envelope,
10 + EventRow,
11 + Facets,
12 + HealthPayload,
13 + HomePayload,
14 + LaunchDetail,
15 + LaunchRow,
16 + LaunchSiteRow,
17 + LaunchTimeline,
18 + MethodologyPayload,
19 + OperatorDetail,
20 + OperatorRow,
21 + OrbitalElementRow,
22 + Paginated,
23 + Problem,
24 + RankingsPayload,
25 + ReentriesPayload,
26 + SatelliteDetail,
27 + SatelliteHistory,
28 + SatelliteRow,
29 + SearchPayload,
30 + SourceRow,
31 + SourcesStatus,
32 + StatsSnapshot,
33 + Track,
34 +} from './types';
35 +
36 +/**
37 + * Typed fetch wrapper for the SatelliteIndex API (server components only — client code calls the same-origin
38 + * `/api/v1/*` rewrite through `src/lib/client-api.ts`).
39 + *
40 + * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store).
41 + * - non-2xx → `ApiError` (status + problem body); network failure → status 0. Pages must render an
42 + * "Unavailable" state rather than crash (graceful degradation).
43 + * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel.
44 + */
45 +export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8311').replace(/\/$/, '');
46 +const BASE = `${API_URL}/api/v1`;
47 +
48 +export class ApiError extends Error {
49 + readonly status: number;
50 + readonly problem: Problem | null;
51 + readonly path: string;
52 + constructor(status: number, path: string, problem: Problem | null, message?: string) {
53 + super(message ?? problem?.detail ?? problem?.title ?? `API ${status} on ${path}`);
54 + this.name = 'ApiError';
55 + this.status = status;
56 + this.problem = problem;
57 + this.path = path;
58 + }
59 + get unavailable(): boolean {
60 + return this.status === 503 || this.status === 0 || this.status >= 500;
61 + }
62 + get notFound(): boolean {
63 + return this.status === 404;
64 + }
65 +}
66 +
67 +export interface FetchOptions {
68 + /** Seconds; `false` → `cache: 'no-store'`. Default 300. */
69 + revalidate?: number | false;
70 + tags?: string[];
71 +}
72 +type Query = Record<string, string | number | boolean | null | undefined>;
73 +
74 +function qs(query?: Query): string {
75 + if (!query) return '';
76 + const p = new URLSearchParams();
77 + for (const [k, v] of Object.entries(query)) {
78 + if (v === undefined || v === null || v === '') continue;
79 + p.set(k, String(v));
80 + }
81 + const s = p.toString();
82 + return s ? `?${s}` : '';
83 +}
84 +
85 +export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {
86 + const url = `${BASE}${path}${qs(query)}`;
87 + const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };
88 + if (opts.revalidate === false) init.cache = 'no-store';
89 + else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags };
90 + let res: Response;
91 + try {
92 + res = await fetch(url, init);
93 + } catch (e) {
94 + throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);
95 + }
96 + if (!res.ok) {
97 + let problem: Problem | null = null;
98 + try {
99 + const body = (await res.json()) as { error?: Problem };
100 + problem = body.error ?? null;
101 + } catch {
102 + /* non-JSON error body */
103 + }
104 + throw new ApiError(res.status, path, problem);
105 + }
106 + return (await res.json()) as T;
107 +}
108 +
109 +export async function safe<T>(p: Promise<T>): Promise<T | null> {
110 + try {
111 + return await p;
112 + } catch {
113 + return null;
114 + }
115 +}
116 +
117 +// ---------------------------------------------------------------------------------------------------------- endpoints
118 +export const api = {
119 + health: () => request<HealthPayload>('/health', undefined, { revalidate: false }),
120 + home: () => request<Envelope<HomePayload>>('/stats/home', undefined, { revalidate: 120 }),
121 + stats: () => request<Envelope<StatsSnapshot>>('/stats', undefined, { revalidate: 300 }),
122 + rankings: (metric: string, limit = 50) => request<Envelope<RankingsPayload>>('/rankings', { metric, limit }, { revalidate: 600 }),
123 + density: () => request<Envelope<DensityPayload>>('/orbit/density', undefined, { revalidate: 900 }),
124 +
125 + satellites: (query: Query) => request<Paginated<SatelliteRow>>('/satellites', query, { revalidate: 120 }),
126 + satelliteFacets: (query: Query) => request<Envelope<Facets>>('/satellites/facets', query, { revalidate: 600 }),
127 + satellite: (ident: string) => request<Envelope<SatelliteDetail>>(`/satellites/${encodeURIComponent(ident)}`, undefined, { revalidate: 60 }),
128 + satelliteOrbit: (ident: string, limit = 200) => request<Envelope<{ elements: OrbitalElementRow[]; count: number }>>(`/satellites/${encodeURIComponent(ident)}/orbit`, { limit }, { revalidate: 300 }),
129 + satelliteTrack: (ident: string) => request<Envelope<Track>>(`/satellites/${encodeURIComponent(ident)}/track`, undefined, { revalidate: false }),
130 + satelliteHistory: (ident: string) => request<Envelope<SatelliteHistory>>(`/satellites/${encodeURIComponent(ident)}/history`, undefined, { revalidate: 300 }),
131 +
132 + search: (q: string, limit = 20) => request<Envelope<SearchPayload>>('/search', { q, limit }, { revalidate: false }),
133 +
134 + constellations: (query: Query = {}) => request<Paginated<ConstellationRow>>('/constellations', { page_size: 100, ...query }, { revalidate: 600 }),
135 + constellation: (slug: string) => request<Envelope<ConstellationDetail>>(`/constellations/${encodeURIComponent(slug)}`, undefined, { revalidate: 300 }),
136 +
137 + operators: (query: Query = {}) => request<Paginated<OperatorRow>>('/operators', { page_size: 100, ...query }, { revalidate: 600 }),
138 + operator: (slug: string) => request<Envelope<OperatorDetail>>(`/operators/${encodeURIComponent(slug)}`, undefined, { revalidate: 300 }),
139 +
140 + countries: (sort = 'active') => request<Envelope<CountryRow[]>>('/countries', { sort }, { revalidate: 900 }),
141 + country: (ident: string) => request<Envelope<CountryDetail>>(`/countries/${encodeURIComponent(ident)}`, undefined, { revalidate: 600 }),
142 +
143 + launches: (query: Query) => request<Paginated<LaunchRow>>('/launches', query, { revalidate: 300 }),
144 + launchTimeline: () => request<Envelope<LaunchTimeline>>('/launches/timeline', undefined, { revalidate: 900 }),
145 + launch: (cospar: string) => request<Envelope<LaunchDetail>>(`/launches/${encodeURIComponent(cospar)}`, undefined, { revalidate: 300 }),
146 + launchSites: () => request<Envelope<LaunchSiteRow[]>>('/launch-sites', undefined, { revalidate: 900 }),
147 + launchSite: (slug: string) => request<Envelope<LaunchSiteRow & { country_name: string | null; country_slug: string | null; years: { year: number; launches: Num; payloads: Num }[]; recent_launches: LaunchRow[]; owners: { code: string; name: string; launches: Num }[] }>>(`/launch-sites/${encodeURIComponent(slug)}`, undefined, { revalidate: 600 }),
148 +
149 + debris: () => request<Envelope<DebrisPayload>>('/debris', undefined, { revalidate: 900 }),
150 + reentries: (query: Query) => request<ReentriesPayload>('/reentries', query, { revalidate: 300 }),
151 +
152 + events: (query: Query) => request<Paginated<EventRow> & { types: { type: string; count: Num; latest: string }[] }>('/events', query, { revalidate: 60 }),
153 + event: (id: string) => request<Envelope<EventRow>>(`/events/${encodeURIComponent(id)}`, undefined, { revalidate: 300 }),
154 +
155 + sources: () => request<Envelope<SourceRow[]>>('/sources', undefined, { revalidate: 60 }),
156 + sourcesStatus: () => request<Envelope<SourcesStatus>>('/sources/status', undefined, { revalidate: false }),
157 + methodology: () => request<Envelope<MethodologyPayload>>('/methodology', undefined, { revalidate: 3600 }),
158 + sitemapSatellites: (page: number, page_size = 5000) => request<Envelope<{ items: { slug: string; updated_at: string; status: string }[]; total: number }>>('/sitemap/satellites', { page, page_size }, { revalidate: 3600 }),
159 + sitemapEntities: () => request<Envelope<{ constellations: { slug: string; updated_at: string }[]; operators: { slug: string; updated_at: string }[]; countries: { slug: string }[]; launches: { slug: string; updated_at: string }[]; launch_sites: { slug: string }[] }>>('/sitemap/entities', undefined, { revalidate: 3600 }),
160 +};
161 +
162 +type Num = number | string | null;
added apps/web/src/lib/client-api.ts +22 −0
@@ -0,0 +1,22 @@
1 +'use client';
2 +/** Browser-side fetches: same origin `/api/v1/*` (Next rewrite → FastAPI). Never import server `api.ts` in client components. */
3 +import type { Envelope, LivePosition, Paginated, PositionsSnapshot, SatelliteRow, SearchPayload, SearchResult, Track } from './types';
4 +
5 +async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
6 + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal });
7 + if (!res.ok) throw new Error(`API ${res.status} on ${path}`);
8 + return (await res.json()) as T;
9 +}
10 +
11 +export const clientApi = {
12 + positions: (signal?: AbortSignal) => get<Envelope<PositionsSnapshot>>('/orbit/positions', signal),
13 + live: (ident: string, signal?: AbortSignal) => get<Envelope<LivePosition & { orbit_class: string | null }>>(`/satellites/${encodeURIComponent(ident)}/live`, signal),
14 + position: (ident: string, signal?: AbortSignal) => get<Envelope<LivePosition & { satellite: { slug: string; name: string; norad_id: number | null } }>>(`/satellites/${encodeURIComponent(ident)}/position`, signal),
15 + track: (ident: string, signal?: AbortSignal) => get<Envelope<Track>>(`/satellites/${encodeURIComponent(ident)}/track`, signal),
16 + satellite: (ident: string, signal?: AbortSignal) => get<Envelope<SatelliteRow>>(`/satellites/${encodeURIComponent(ident)}`, signal),
17 + satellites: (qs: string, signal?: AbortSignal) => get<Paginated<SatelliteRow>>(`/satellites?${qs}`, signal),
18 + search: (q: string, limit = 12, signal?: AbortSignal) => get<Envelope<SearchPayload>>(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, signal),
19 + suggest: (q: string, signal?: AbortSignal) => get<Envelope<SearchResult[]>>(`/search/suggest?q=${encodeURIComponent(q)}`, signal),
20 + view: (entity_type: string, entity_id: string) =>
21 + fetch('/api/v1/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ entity_type, entity_id }), keepalive: true }).catch(() => undefined),
22 +};
added apps/web/src/lib/cn.ts +3 −0
@@ -0,0 +1,3 @@
1 +export function cn(...parts: Array<string | false | null | undefined>): string {
2 + return parts.filter(Boolean).join(' ');
3 +}
added apps/web/src/lib/fonts.system.ts +3 −0
@@ -0,0 +1,3 @@
1 +/** Offline fallback for fonts.ts: same exports, system font stacks. */
2 +export const fontUi = { variable: 'font-ui-system', className: '' };
3 +export const fontMono = { variable: 'font-mono-system', className: '' };
added apps/web/src/lib/fonts.ts +8 −0
@@ -0,0 +1,8 @@
1 +/**
2 + * Fonts via next/font/google (self-hosted at build). If the build machine has no network, swap this import in
3 + * layout.tsx for `./fonts.system` (same exported names, system stack) — the build must never fail on fonts.
4 + */
5 +import { Inter, JetBrains_Mono } from 'next/font/google';
6 +
7 +export const fontUi = Inter({ variable: '--font-ui', subsets: ['latin'], display: 'swap' });
8 +export const fontMono = JetBrains_Mono({ variable: '--font-mono', subsets: ['latin'], display: 'swap', weight: ['400', '500', '600'] });
added apps/web/src/lib/format.ts +90 −0
@@ -0,0 +1,90 @@
1 +/** Formatting helpers. All API timestamps are UTC ISO strings; we render UTC explicitly (orbital convention). */
2 +
3 +const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
4 +const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 });
5 +const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2, minimumFractionDigits: 2 });
6 +const nfCompact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
7 +
8 +export function fmtInt(v: number | string | null | undefined): string {
9 + if (v === null || v === undefined || v === '') return '—';
10 + const n = typeof v === 'string' ? Number(v) : v;
11 + return Number.isFinite(n) ? nf0.format(n) : '—';
12 +}
13 +export function fmt1(v: number | string | null | undefined): string {
14 + if (v === null || v === undefined || v === '') return '—';
15 + const n = typeof v === 'string' ? Number(v) : v;
16 + return Number.isFinite(n) ? nf1.format(n) : '—';
17 +}
18 +export function fmt2(v: number | string | null | undefined): string {
19 + if (v === null || v === undefined || v === '') return '—';
20 + const n = typeof v === 'string' ? Number(v) : v;
21 + return Number.isFinite(n) ? nf2.format(n) : '—';
22 +}
23 +export function fmtCompact(v: number | string | null | undefined): string {
24 + if (v === null || v === undefined || v === '') return '—';
25 + const n = typeof v === 'string' ? Number(v) : v;
26 + return Number.isFinite(n) ? nfCompact.format(n) : '—';
27 +}
28 +export function fmtKm(v: number | string | null | undefined, digits = 0): string {
29 + if (v === null || v === undefined || v === '') return '—';
30 + const n = typeof v === 'string' ? Number(v) : v;
31 + if (!Number.isFinite(n)) return '—';
32 + return `${digits ? nf1.format(n) : nf0.format(n)} km`;
33 +}
34 +export function fmtDeg(v: number | string | null | undefined): string {
35 + if (v === null || v === undefined || v === '') return '—';
36 + const n = typeof v === 'string' ? Number(v) : v;
37 + return Number.isFinite(n) ? `${nf2.format(n)}°` : '—';
38 +}
39 +export function fmtMinutes(v: number | string | null | undefined): string {
40 + if (v === null || v === undefined || v === '') return '—';
41 + const n = typeof v === 'string' ? Number(v) : v;
42 + if (!Number.isFinite(n)) return '—';
43 + if (n >= 1440) return `${nf1.format(n / 1440)} d`;
44 + if (n >= 120) return `${nf1.format(n / 60)} h`;
45 + return `${nf1.format(n)} min`;
46 +}
47 +export function fmtPct(v: number | string | null | undefined, digits = 1): string {
48 + if (v === null || v === undefined || v === '') return '—';
49 + const n = typeof v === 'string' ? Number(v) : v;
50 + return Number.isFinite(n) ? `${n.toFixed(digits)}%` : '—';
51 +}
52 +
53 +export function fmtDate(v: string | null | undefined): string {
54 + if (!v) return '—';
55 + const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v);
56 + if (Number.isNaN(d.getTime())) return '—';
57 + return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' });
58 +}
59 +export function fmtDateTime(v: string | null | undefined): string {
60 + if (!v) return '—';
61 + const d = new Date(v);
62 + if (Number.isNaN(d.getTime())) return '—';
63 + return `${d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' })} ${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC`;
64 +}
65 +export function fmtAgo(v: string | null | undefined, now: number = Date.now()): string {
66 + if (!v) return 'unavailable';
67 + const t = new Date(v).getTime();
68 + if (Number.isNaN(t)) return 'unavailable';
69 + const s = Math.max(0, Math.round((now - t) / 1000));
70 + if (s < 60) return 'just now';
71 + const m = Math.round(s / 60);
72 + if (m < 60) return `${m} min ago`;
73 + const h = Math.round(m / 60);
74 + if (h < 48) return `${h} h ago`;
75 + const d = Math.round(h / 24);
76 + if (d < 60) return `${d} d ago`;
77 + return fmtDate(v);
78 +}
79 +export function fmtYear(v: string | null | undefined): string {
80 + return v ? v.slice(0, 4) : '—';
81 +}
82 +export function titleCase(s: string | null | undefined): string {
83 + if (!s) return '—';
84 + return s.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
85 +}
86 +export function num(v: number | string | null | undefined): number | null {
87 + if (v === null || v === undefined || v === '') return null;
88 + const n = typeof v === 'string' ? Number(v) : v;
89 + return Number.isFinite(n) ? n : null;
90 +}
added apps/web/src/lib/site.ts +118 −0
@@ -0,0 +1,118 @@
1 +export const SITE_NAME = 'SatelliteIndex';
2 +export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.satelliteindex.io').replace(/\/$/, '');
3 +export const TAGLINE = "The world's orbital infrastructure, mapped and indexed";
4 +export const DESCRIPTION =
5 + 'SatelliteIndex is the public intelligence layer for everything in Earth orbit: satellites, constellations, operators, countries, launches, debris and reentries — with live SGP4 positions, orbital history and transparent sources.';
6 +export const CONTACT_EMAIL = 'contact@spboucher.ai';
7 +export const AUTHOR = 'Simon-Pierre Boucher';
8 +
9 +export const routes = {
10 + home: () => '/',
11 + explore: () => '/explore',
12 + search: (q?: string) => (q ? `/search?q=${encodeURIComponent(q)}` : '/search'),
13 + satellites: (qs?: string) => (qs ? `/satellites?${qs}` : '/satellites'),
14 + satellite: (slug: string) => `/satellite/${slug}`,
15 + constellations: () => '/constellations',
16 + constellation: (slug: string) => `/constellation/${slug}`,
17 + operators: () => '/operators',
18 + operator: (slug: string) => `/operator/${slug}`,
19 + countries: () => '/countries',
20 + country: (slug: string) => `/country/${slug}`,
21 + launches: (qs?: string) => (qs ? `/launches?${qs}` : '/launches'),
22 + launch: (cospar: string) => `/launch/${cospar}`,
23 + launchSites: () => '/launch-sites',
24 + launchSite: (slug: string) => `/launch-sites/${slug}`,
25 + debris: () => '/debris',
26 + reentries: () => '/reentries',
27 + events: (qs?: string) => (qs ? `/events?${qs}` : '/events'),
28 + stats: () => '/stats',
29 + rankings: (metric?: string) => (metric ? `/rankings?metric=${metric}` : '/rankings'),
30 + sources: () => '/sources',
31 + methodology: () => '/methodology',
32 + status: () => '/status',
33 + statusData: () => '/status/data',
34 + developers: () => '/developers',
35 + about: () => '/about',
36 + privacy: () => '/privacy',
37 + terms: () => '/terms',
38 + admin: () => '/admin',
39 +} as const;
40 +
41 +export const primaryNav = [
42 + { href: routes.explore(), label: 'Explore' },
43 + { href: routes.satellites(), label: 'Satellites' },
44 + { href: routes.constellations(), label: 'Constellations' },
45 + { href: routes.operators(), label: 'Operators' },
46 + { href: routes.countries(), label: 'Countries' },
47 + { href: routes.launches(), label: 'Launches' },
48 + { href: routes.events(), label: 'Events' },
49 + { href: routes.stats(), label: 'Stats' },
50 +] as const;
51 +
52 +export const moreNav = [
53 + { href: routes.debris(), label: 'Debris' },
54 + { href: routes.reentries(), label: 'Reentries' },
55 + { href: routes.rankings(), label: 'Rankings' },
56 + { href: routes.launchSites(), label: 'Launch sites' },
57 + { href: routes.sources(), label: 'Sources' },
58 + { href: routes.methodology(), label: 'Methodology' },
59 + { href: routes.status(), label: 'Status' },
60 + { href: routes.developers(), label: 'API' },
61 + { href: routes.about(), label: 'About' },
62 +] as const;
63 +
64 +export const ORBIT_CLASS_COLORS: Record<string, string> = {
65 + LEO: 'var(--leo)',
66 + MEO: 'var(--meo)',
67 + GEO: 'var(--geo)',
68 + HEO: 'var(--heo)',
69 + OTHER: 'var(--other)',
70 + UNKNOWN: 'var(--other)',
71 +};
72 +
73 +export const STATUS_COLORS: Record<string, string> = {
74 + ACTIVE: 'var(--active)',
75 + INACTIVE: 'var(--inactive)',
76 + DECAYED: 'var(--ink-3)',
77 + UNKNOWN: 'var(--warn)',
78 + LOST: 'var(--danger)',
79 + FAILED: 'var(--danger)',
80 + PLANNED: 'var(--accent-2)',
81 +};
82 +
83 +export const MISSION_LABELS: Record<string, string> = {
84 + communications: 'Communications',
85 + 'earth-observation': 'Earth observation',
86 + navigation: 'Navigation',
87 + weather: 'Weather',
88 + science: 'Science',
89 + military: 'Military',
90 + technology: 'Technology',
91 + iot: 'IoT / M2M',
92 + station: 'Space station',
93 + 'rocket-body': 'Rocket body',
94 + debris: 'Debris',
95 + unknown: 'Unknown',
96 +};
97 +
98 +export const OBJECT_TYPE_LABELS: Record<string, string> = {
99 + PAYLOAD: 'Payload',
100 + ROCKET_BODY: 'Rocket body',
101 + DEBRIS: 'Debris',
102 + STATION: 'Space station',
103 + UNKNOWN: 'Unknown',
104 + CREWED: 'Crewed',
105 +};
106 +
107 +export const EVENT_TYPE_LABELS: Record<string, string> = {
108 + SATELLITE_LAUNCH: 'Launch',
109 + DECAY: 'Decay / reentry',
110 + ORBIT_CHANGE: 'Orbit change',
111 + SATELLITE_DECOMMISSION: 'Decommission',
112 + SATELLITE_ACTIVATION: 'Activation',
113 + REENTRY: 'Reentry',
114 + CONSTELLATION_EXPANSION: 'Constellation expansion',
115 + REGULATORY_APPROVAL: 'Regulatory approval',
116 + NEW_LICENSE: 'New license',
117 + COMPANY_ANNOUNCEMENT: 'Announcement',
118 +};
added apps/web/src/lib/types.ts +559 −0
@@ -0,0 +1,559 @@
1 +/** API contract (FastAPI /api/v1). Numbers coming from Postgres aggregates may arrive as strings — use `num()` from format.ts. */
2 +
3 +export type Num = number | string | null;
4 +
5 +export interface Meta {
6 + request_id: string;
7 + generated_at: string;
8 + [k: string]: unknown;
9 +}
10 +export interface Envelope<T> {
11 + data: T;
12 + meta: Meta;
13 +}
14 +export interface Paginated<T> {
15 + data: T[];
16 + pagination: { page: number; page_size: number; total: number; pages: number };
17 + meta: Meta;
18 + [k: string]: unknown;
19 +}
20 +export interface Problem {
21 + title: string;
22 + detail?: string | null;
23 + status: number;
24 +}
25 +
26 +export type OrbitClass = 'LEO' | 'MEO' | 'GEO' | 'HEO' | 'OTHER';
27 +export type ObjectType = 'PAYLOAD' | 'ROCKET_BODY' | 'DEBRIS' | 'STATION' | 'UNKNOWN' | 'CREWED';
28 +export type SatStatus = 'ACTIVE' | 'INACTIVE' | 'DECAYED' | 'LOST' | 'FAILED' | 'UNKNOWN' | 'PLANNED';
29 +
30 +export interface SatelliteRow {
31 + id: string;
32 + slug: string;
33 + name: string;
34 + norad_id: number | null;
35 + cospar_id: string | null;
36 + object_type: ObjectType;
37 + status: SatStatus;
38 + ops_status_code: string | null;
39 + mission_type: string | null;
40 + orbit_class: OrbitClass | null;
41 + period_minutes: Num;
42 + inclination_deg: Num;
43 + apogee_km: Num;
44 + perigee_km: Num;
45 + rcs_m2: Num;
46 + launch_date: string | null;
47 + decay_date: string | null;
48 + launch_site_code: string | null;
49 + has_gp: boolean;
50 + latest_epoch: string | null;
51 + country_code: string | null;
52 + country_name: string | null;
53 + country_slug: string | null;
54 + owner_code: string | null;
55 + owner_name: string | null;
56 + operator_id: string | null;
57 + operator_name: string | null;
58 + operator_slug: string | null;
59 + constellation_id: string | null;
60 + constellation_name: string | null;
61 + constellation_slug: string | null;
62 + launch_id: string | null;
63 + cospar_launch_id: string | null;
64 + launch_site_name: string | null;
65 + launch_site_slug: string | null;
66 + first_seen_at: string;
67 + last_seen_at: string;
68 + updated_at: string;
69 +}
70 +
71 +export interface OrbitalState {
72 + epoch: string;
73 + mean_motion: number;
74 + eccentricity: number;
75 + inclination: number;
76 + raan: number;
77 + arg_of_perigee: number;
78 + mean_anomaly: number;
79 + bstar: number | null;
80 + mean_motion_dot: number | null;
81 + semi_major_axis_km: number | null;
82 + perigee_km: number | null;
83 + apogee_km: number | null;
84 + period_minutes: number | null;
85 + orbit_class: OrbitClass | null;
86 + source_id: string;
87 + updated_at: string;
88 +}
89 +
90 +export interface LivePosition {
91 + lat: number;
92 + lon: number;
93 + altitude_km: number;
94 + velocity_km_s: number;
95 + timestamp: string;
96 + source_epoch: string;
97 + epoch_age_hours?: number;
98 + error?: string | null;
99 +}
100 +
101 +export interface Freshness {
102 + orbit: { updated_at: string | null; epoch: string | null; status: 'fresh' | 'aging' | 'stale' | 'unavailable' };
103 + metadata: { updated_at: string | null; status: 'fresh' | 'aging' | 'stale' | 'unavailable' };
104 +}
105 +
106 +export interface SatelliteDetail extends SatelliteRow {
107 + redirected_from?: string;
108 + orbital_state: OrbitalState | null;
109 + live: LivePosition | null;
110 + aliases: { alias: string; source_id: string | null }[];
111 + tags: { tag: string; source_id: string | null; last_seen_at: string }[];
112 + identifiers: { identifier_type: string; identifier_value: string; source_id: string | null; verified: boolean }[];
113 + history: { field: string; old_value: string | null; new_value: string | null; source_id: string | null; changed_at: string }[];
114 + constellation_memberships: { constellation_id: string; slug: string; name: string; method: string; since: string; until: string | null }[];
115 + provenance: { field_name: string; source_id: string; source_name: string; observed_at: string; confidence: number }[];
116 + events: EventRow[];
117 + quality_flags: { flag: string; detail: string | null; created_at: string }[];
118 + launch_siblings: Pick<SatelliteRow, 'id' | 'slug' | 'name' | 'norad_id' | 'object_type' | 'status'>[];
119 + related: Pick<SatelliteRow, 'id' | 'slug' | 'name' | 'norad_id' | 'status' | 'perigee_km'>[];
120 + sources: { id: string; name: string; official: boolean; attribution_text: string | null; last_success_at: string | null }[];
121 + freshness: Freshness;
122 +}
123 +
124 +export interface TrackPoint {
125 + t: string;
126 + lat: number;
127 + lon: number;
128 + alt: number;
129 + future: boolean;
130 +}
131 +export interface Track {
132 + t0: string;
133 + points: TrackPoint[];
134 + source_epoch: string;
135 +}
136 +
137 +export interface OrbitalElementRow {
138 + epoch: string;
139 + mean_motion: number;
140 + eccentricity: number;
141 + inclination: number;
142 + raan: number;
143 + arg_of_perigee: number;
144 + mean_anomaly: number;
145 + bstar: number | null;
146 + semi_major_axis_km: number | null;
147 + perigee_km: number | null;
148 + apogee_km: number | null;
149 + period_minutes: number | null;
150 + source_id: string;
151 + received_at: string;
152 +}
153 +
154 +export interface SatelliteHistory {
155 + changes: { field: string; old_value: string | null; new_value: string | null; source_id: string | null; changed_at: string }[];
156 + altitude_series: { day: string; perigee_km: Num; apogee_km: Num; period_minutes: Num; inclination: Num }[];
157 +}
158 +
159 +export interface FacetValue {
160 + value: string;
161 + count: Num;
162 + label?: string;
163 + slug?: string;
164 +}
165 +export interface Facets {
166 + status: FacetValue[];
167 + object_type: FacetValue[];
168 + orbit_class: FacetValue[];
169 + mission_type: FacetValue[];
170 + country: FacetValue[];
171 + constellation: FacetValue[];
172 +}
173 +
174 +export interface ConstellationRow {
175 + id: string;
176 + slug: string;
177 + name: string;
178 + service_type: string | null;
179 + orbit_class: string | null;
180 + lifecycle_stage: string;
181 + description: string | null;
182 + official_url: string | null;
183 + planned_count: number | null;
184 + authorized_count: number | null;
185 + country_code: string | null;
186 + country_name: string | null;
187 + country_slug: string | null;
188 + operator_id: string | null;
189 + operator_name: string | null;
190 + operator_slug: string | null;
191 + active: Num;
192 + inactive: Num;
193 + decayed: Num;
194 + on_orbit: Num;
195 + total: Num;
196 + launched_last_365d: Num;
197 + launched_last_30d: Num;
198 + launches: Num;
199 + first_launch: string | null;
200 + last_launch: string | null;
201 + median_perigee_km: Num;
202 + median_inclination_deg: Num;
203 + activity_score: Num;
204 +}
205 +
206 +export interface ConstellationDetail extends ConstellationRow {
207 + status_distribution: { status: string; count: Num }[];
208 + growth: { month: string; launched: Num; cumulative: Num }[];
209 + shells: { perigee_km: Num; inclination_deg: Num; satellites: Num }[];
210 + altitude_histogram: { alt_km: Num; satellites: Num }[];
211 + inclination_histogram: { incl_deg: Num; satellites: Num }[];
212 + launches_list: { id: string; cospar_launch_id: string; launch_date: string | null; site_name: string | null; site_slug: string | null; satellites: Num; active: Num }[];
213 + launch_sites: { code: string; name: string; slug: string; country_code: string | null; launches: Num; satellites: Num }[];
214 + countries: { code: string; name: string; slug: string; satellites: Num }[];
215 + recent_satellites: { id: string; slug: string; name: string; norad_id: number | null; status: string; launch_date: string | null; perigee_km: Num; apogee_km: Num; inclination_deg: Num }[];
216 + events: EventRow[];
217 + decays_by_month: { month: string; decayed: Num }[];
218 + membership_methods: { method: string; satellites: Num }[];
219 + match_patterns: string[];
220 + celestrak_groups: string[];
221 +}
222 +
223 +export interface OperatorRow {
224 + id: string;
225 + slug: string;
226 + name: string;
227 + kind: string;
228 + official_url: string | null;
229 + description: string | null;
230 + country_code: string | null;
231 + country_name: string | null;
232 + country_slug: string | null;
233 + active_payloads: Num;
234 + on_orbit_payloads: Num;
235 + total_payloads: Num;
236 + decayed: Num;
237 + constellations: Num;
238 + launches: Num;
239 + payloads_last_365d: Num;
240 + first_launch: string | null;
241 + last_launch: string | null;
242 +}
243 +
244 +export interface OperatorDetail extends OperatorRow {
245 + aliases: string[];
246 + constellations_list: { id: string; slug: string; name: string; service_type: string | null; orbit_class: string | null; active: Num; total: Num; launched_last_365d: Num }[];
247 + launches_list: { id: string; cospar_launch_id: string; launch_date: string | null; site_name: string | null; site_slug: string | null; satellites: Num }[];
248 + status_distribution: { status: string; count: Num }[];
249 + orbit_distribution: { orbit_class: string; count: Num }[];
250 + mission_distribution: { mission_type: string; count: Num }[];
251 + growth: { year: number; launched: Num; still_active: Num }[];
252 + fleet_sample: { id: string; slug: string; name: string; norad_id: number | null; status: string; orbit_class: string | null; launch_date: string | null; perigee_km: Num; mission_type: string | null }[];
253 + events: EventRow[];
254 + launch_sites: { code: string; name: string; slug: string; country_code: string | null; launches: Num }[];
255 +}
256 +
257 +export interface CountryRow {
258 + code: string;
259 + name: string;
260 + slug: string;
261 + iso3?: string | null;
262 + region?: string | null;
263 + active_payloads: Num;
264 + on_orbit_payloads: Num;
265 + total_payloads: Num;
266 + debris_on_orbit: Num;
267 + rocket_bodies_on_orbit: Num;
268 + objects_on_orbit: Num;
269 + total_objects: Num;
270 + launches: Num;
271 + operators: Num;
272 + payloads_last_365d: Num;
273 + rank_active?: Num;
274 +}
275 +
276 +export interface CountryDetail extends CountryRow {
277 + rank_objects: Num;
278 + rank_debris: Num;
279 + operators_list: { id: string; slug: string; name: string; kind: string; active_payloads: Num; total_payloads: Num; payloads_last_365d: Num }[];
280 + constellations_list: { id: string; slug: string; name: string; service_type: string | null; orbit_class: string | null; active: Num; total: Num }[];
281 + owner_codes: { code: string; name: string; kind: string }[];
282 + orbit_distribution: { orbit_class: string; count: Num }[];
283 + mission_distribution: { mission_type: string; count: Num }[];
284 + object_type_distribution: { object_type: string; on_orbit: Num; total: Num }[];
285 + growth: { year: number; payloads: Num; still_active: Num; launches: Num }[];
286 + launch_sites: { code: string; name: string; slug: string; latitude: number | null; longitude: number | null; launches: Num; last_launch: string | null }[];
287 + recent_satellites: { id: string; slug: string; name: string; norad_id: number | null; status: string; object_type: string; orbit_class: string | null; launch_date: string | null }[];
288 + recent_launches: LaunchRow[];
289 + events: EventRow[];
290 +}
291 +
292 +export interface LaunchRow {
293 + id: string;
294 + cospar_launch_id: string;
295 + launch_date: string | null;
296 + launch_year?: number | null;
297 + payload_count: Num;
298 + object_count?: Num;
299 + on_orbit_count?: Num;
300 + primary_name: string | null;
301 + owner_codes?: string[];
302 + site_code?: string | null;
303 + site_name: string | null;
304 + site_slug: string | null;
305 + site_country?: string | null;
306 + site_lat?: number | null;
307 + site_lon?: number | null;
308 +}
309 +
310 +export interface LaunchDetail extends LaunchRow {
311 + objects: (Pick<SatelliteRow, 'id' | 'slug' | 'name' | 'norad_id' | 'cospar_id' | 'object_type' | 'status' | 'orbit_class' | 'perigee_km' | 'apogee_km' | 'inclination_deg' | 'decay_date' | 'country_code' | 'operator_name' | 'operator_slug' | 'constellation_name' | 'constellation_slug'>)[];
312 + owners: { code: string; name: string; kind: string; country_code: string | null }[];
313 + events: EventRow[];
314 +}
315 +
316 +export interface LaunchSiteRow {
317 + code: string;
318 + name: string;
319 + slug: string;
320 + country_code: string | null;
321 + country_name?: string | null;
322 + latitude: number | null;
323 + longitude: number | null;
324 + launches: Num;
325 + launches_last_365d: Num;
326 + payloads?: Num;
327 + first_launch?: string | null;
328 + last_launch: string | null;
329 +}
330 +
331 +export interface EventEntity {
332 + type: string;
333 + id: string;
334 + relationship?: string;
335 + slug: string | null;
336 + name: string | null;
337 + norad_id?: number | null;
338 +}
339 +export interface EventRow {
340 + id: string;
341 + type: string;
342 + title: string;
343 + summary: string | null;
344 + event_time: string;
345 + detected_at?: string;
346 + confidence: number;
347 + source_id: string | null;
348 + source_name?: string | null;
349 + source_url?: string | null;
350 + metadata?: Record<string, unknown>;
351 + entities?: EventEntity[] | null;
352 +}
353 +
354 +export interface GlobalStats {
355 + active_satellites: Num;
356 + objects_on_orbit: Num;
357 + objects_catalogued: Num;
358 + payloads_total: Num;
359 + payloads_on_orbit: Num;
360 + debris_on_orbit: Num;
361 + rocket_bodies_on_orbit: Num;
362 + decayed_objects: Num;
363 + decayed_last_30d: Num;
364 + decayed_last_365d: Num;
365 + with_elements: Num;
366 + payloads_launched_30d: Num;
367 + payloads_launched_365d: Num;
368 + payloads_launched_ytd: Num;
369 + active_operators: Num;
370 + active_countries: Num;
371 + active_constellations: Num;
372 + latest_epoch: string | null;
373 +}
374 +export interface LaunchTotals {
375 + total: Num;
376 + last_365d: Num;
377 + ytd: Num;
378 + last_30d: Num;
379 +}
380 +export interface BucketStat {
381 + bucket: string;
382 + objects: Num;
383 + active_payloads: Num;
384 + payloads: Num;
385 + debris: Num;
386 + rocket_bodies: Num;
387 +}
388 +export interface StatsSnapshot {
389 + computed_at: string;
390 + global: GlobalStats;
391 + launches: LaunchTotals;
392 + by_orbit_class: { orbit_class: string; active: Num; on_orbit: Num }[];
393 + by_object_type: { object_type: string; on_orbit: Num; total: Num }[];
394 + by_mission_type: { mission_type: string; active: Num }[];
395 + launches_by_year: { year: number; launches: Num; payloads: Num }[];
396 + payloads_by_launch_year: { year: number; payloads: Num; still_active: Num }[];
397 + decays_by_year: { year: number; decayed: Num }[];
398 + top_countries: CountryRow[];
399 + top_operators: { id: string; slug: string; name: string; country_code: string | null; active_payloads: Num; total_payloads: Num; payloads_last_365d: Num }[];
400 + top_constellations: { id: string; slug: string; name: string; operator_id: string | null; service_type: string | null; orbit_class: string | null; active: Num; on_orbit: Num; total: Num; launched_last_365d: Num; launched_last_30d: Num }[];
401 + orbital_buckets: BucketStat[];
402 + connectors: { name: string; source_id: string; last_success_at: string | null; last_attempt_at: string | null; interval_seconds: number; enabled: boolean; circuit_open_until: string | null; consecutive_failures: number }[];
403 +}
404 +
405 +export interface HomePayload {
406 + stats: GlobalStats;
407 + launches: LaunchTotals;
408 + by_orbit_class: StatsSnapshot['by_orbit_class'];
409 + top_constellations: StatsSnapshot['top_constellations'];
410 + top_countries: CountryRow[];
411 + top_operators: StatsSnapshot['top_operators'];
412 + orbital_buckets: BucketStat[];
413 + latest_launches: LaunchRow[];
414 + events: EventRow[];
415 + reentries: { id: string; slug: string; name: string; norad_id: number | null; object_type: string; decay_date: string; country_code: string | null; country_name: string | null }[];
416 + trending: { id: string; slug: string; name: string; norad_id: number | null; status: string; orbit_class: string | null; perigee_km: Num; constellation_name: string | null; operator_name: string | null; views: Num }[];
417 + sources: { id: string; name: string; official: boolean; attribution_text: string | null; connector: string | null; last_success_at: string | null; interval_seconds: number | null; enabled: boolean }[];
418 + computed_at: string;
419 +}
420 +
421 +export interface PositionsSnapshot {
422 + t0: string;
423 + t1: string;
424 + step_s: number;
425 + count: number;
426 + total_tracked: number;
427 + fields: string[];
428 + norad: number[];
429 + cls: number[];
430 + mission: number[];
431 + active: number[];
432 + pos: number[]; // lat0, lon0, alt0, lat1, lon1, alt1 per object
433 + vel: number[];
434 + legend: { cls: string[]; mission: string[] };
435 +}
436 +
437 +export interface SearchResult {
438 + entity_type: 'satellite' | 'operator' | 'constellation' | 'country' | 'launch' | 'launch_site';
439 + entity_id: string;
440 + slug: string;
441 + title: string;
442 + subtitle: string | null;
443 + score: number;
444 + href: string;
445 +}
446 +export interface SearchPayload {
447 + query: string;
448 + results: SearchResult[];
449 + shortcuts: { label: string; href: string; filter: Record<string, string> }[];
450 +}
451 +
452 +export interface SourceRow {
453 + id: string;
454 + name: string;
455 + type: string;
456 + base_url: string | null;
457 + official: boolean;
458 + country_code: string | null;
459 + authority_type: string | null;
460 + license: string | null;
461 + attribution_required: boolean;
462 + attribution_text: string | null;
463 + update_frequency_seconds: number | null;
464 + enabled: boolean;
465 + priority: number;
466 + connectors: { name: string; description: string | null; interval_seconds: number; enabled: boolean; last_success_at: string | null; last_attempt_at: string | null; last_duration_ms: number | null; consecutive_failures: number; circuit_open_until: string | null; next_run_at: string | null }[] | null;
467 + provenance_rows: Num;
468 + raw_snapshots: Num;
469 + last_snapshot_at: string | null;
470 + freshness: 'fresh' | 'aging' | 'stale' | 'unavailable' | 'not_enabled';
471 +}
472 +
473 +export interface ConnectorStatus {
474 + name: string;
475 + source_id: string;
476 + source_name: string;
477 + enabled: boolean;
478 + interval_seconds: number;
479 + last_success_at: string | null;
480 + last_attempt_at: string | null;
481 + last_duration_ms: number | null;
482 + consecutive_failures: number;
483 + circuit_open_until: string | null;
484 + next_run_at: string | null;
485 + last_status: string | null;
486 + last_error: string | null;
487 + freshness: string;
488 +}
489 +export interface SourcesStatus {
490 + connectors: ConnectorStatus[];
491 + orbit: { latest_epoch: string | null; median_element_age_hours: number | null; propagator_objects: number };
492 +}
493 +
494 +export interface HealthPayload {
495 + status: string;
496 + version: string;
497 + time: string;
498 + components: Record<string, { status: string; [k: string]: unknown }>;
499 +}
500 +
501 +export interface MethodologyPayload {
502 + metrics: { key: string; name: string; version: string; methodology: string; inputs: string[]; updated_at: string }[];
503 + constellation_rules: { slug: string; name: string; match_patterns: string[]; celestrak_groups: string[]; service_type: string | null }[];
504 + owner_codes: { code: string; name: string; kind: string; country_code: string | null }[];
505 +}
506 +
507 +export interface DensityPayload {
508 + buckets: BucketStat[];
509 + leo_profile_25km: { alt_km: Num; objects: Num; active_payloads: Num; debris: Num; rocket_bodies: Num }[];
510 + inclination_profile_5deg: { incl_deg: Num; objects: Num; active_payloads: Num }[];
511 + methodology: string;
512 +}
513 +
514 +export interface DebrisPayload {
515 + totals: { debris: Num; rocket_bodies: Num; unknown: Num; inactive_payloads: Num };
516 + by_country: { code: string; name: string; slug: string; debris: Num; rocket_bodies: Num }[];
517 + by_altitude: { alt_km: Num; debris: Num; rocket_bodies: Num }[];
518 + by_launch: { cospar_launch_id: string; launch_date: string | null; primary_name: string | null; site_name: string | null; debris_on_orbit: Num; owner_codes: string[] }[];
519 + debris_growth: { year: number; debris_catalogued: Num; debris_still_on_orbit: Num }[];
520 + decays_by_year: { year: number; debris: Num; rocket_bodies: Num; payloads: Num }[];
521 + largest_objects: { id: string; slug: string; name: string; norad_id: number | null; object_type: string; rcs_m2: Num; perigee_km: Num; apogee_km: Num; launch_date: string | null; country_code: string | null }[];
522 + disclaimer: string;
523 +}
524 +
525 +export interface ReentryRow {
526 + id: string;
527 + slug: string;
528 + name: string;
529 + norad_id: number | null;
530 + cospar_id: string | null;
531 + object_type: string;
532 + decay_date: string;
533 + launch_date: string | null;
534 + country_code: string | null;
535 + country_name: string | null;
536 + rcs_m2: Num;
537 + perigee_km: Num;
538 + apogee_km: Num;
539 + operator_name: string | null;
540 + constellation_name: string | null;
541 + constellation_slug: string | null;
542 +}
543 +export interface ReentriesPayload extends Paginated<ReentryRow> {
544 + summary: { last_7d: Num; last_30d: Num; last_365d: Num };
545 + monthly: { month: string; decayed: Num; payloads: Num; debris: Num; rocket_bodies: Num }[];
546 + low_perigee_watch: { id: string; slug: string; name: string; norad_id: number | null; object_type: string; perigee_km: Num; apogee_km: Num; epoch: string; country_code: string | null; constellation_name: string | null }[];
547 + disclaimer: string;
548 +}
549 +
550 +export interface LaunchTimeline {
551 + years: { year: number; launches: Num; payloads: Num; us: Num; russia_cis: Num; china: Num; other: Num }[];
552 + months: { month: string; launches: Num; payloads: Num }[];
553 + sites: LaunchSiteRow[];
554 +}
555 +
556 +export interface RankingsPayload {
557 + metric: string;
558 + rows: Record<string, unknown>[];
559 +}
added apps/web/tsconfig.json +22 −0
@@ -0,0 +1,22 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noUncheckedIndexedAccess": true,
9 + "noEmit": true,
10 + "esModuleInterop": true,
11 + "module": "esnext",
12 + "moduleResolution": "bundler",
13 + "resolveJsonModule": true,
14 + "isolatedModules": true,
15 + "jsx": "react-jsx",
16 + "incremental": true,
17 + "plugins": [{ "name": "next" }],
18 + "paths": { "@/*": ["./src/*"] }
19 + },
20 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
21 + "exclude": ["node_modules", "qa"]
22 +}
added deploy/first-run.sh +30 −0
@@ -0,0 +1,30 @@
1 +#!/bin/bash
2 +# First ingestion on the production node (run ON the node, from ~/apps/satelliteindex). Idempotent.
3 +# CelesTrak refuses a second download of the same GP group within 2 h (HTTP 403 "has not updated") — the connector treats it as
4 +# unchanged. If a local snapshot exists in ~/satelliteindex-data/seed/ (active.json, satcat.csv) it is used for the very first run.
5 +set -euo pipefail
6 +cd "$(dirname "$0")/.."
7 +export PATH="/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH"
8 +export DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex}
9 +export REDIS_URL=${REDIS_URL:-redis://127.0.0.1:6379/4}
10 +export SI_DATA_DIR=${SI_DATA_DIR:-$HOME/satelliteindex-data}
11 +export SI_LOG_JSON=0
12 +SI=.venv/bin/si
13 +SEED=$SI_DATA_DIR/seed
14 +
15 +$SI migrate | tail -1
16 +$SI seed | tail -1
17 +n=$(psql "postgresql://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex" -Atc "select count(*) from satellites")
18 +echo "satellites in db: $n"
19 +if [ "$n" -lt 1000 ] && [ -s "$SEED/satcat.csv" ]; then
20 + echo "seeding SATCAT from local snapshot"; $SI run celestrak_satcat --file satcat.csv="$SEED/satcat.csv" || true
21 +fi
22 +$SI run celestrak_satcat || true
23 +gp=$(psql "postgresql://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex" -Atc "select count(*) from orbital_state")
24 +if [ "$gp" -lt 1000 ] && [ -s "$SEED/active.json" ]; then
25 + echo "seeding GP from local snapshot"; $SI run celestrak_gp --file active="$SEED/active.json" || true
26 +fi
27 +$SI run celestrak_gp || true
28 +$SI run celestrak_groups || true
29 +$SI run derived_analytics
30 +$SI stats
added deploy/render-manifest.sh +15 −0
@@ -0,0 +1,15 @@
1 +#!/bin/bash
2 +# Render deploy/satelliteindex.mld.json with the real admin token and push it to the mld gateway (M1M32:~/dispatch/apps/).
3 +# Usage: deploy/render-manifest.sh [--push]
4 +set -euo pipefail
5 +cd "$(dirname "$0")/.."
6 +TOKEN_FILE=deploy/.admin-token
7 +[ -s "$TOKEN_FILE" ] || { openssl rand -hex 24 > "$TOKEN_FILE"; echo "generated $TOKEN_FILE"; }
8 +TOKEN=$(tr -d '\n' < "$TOKEN_FILE")
9 +mkdir -p deploy/rendered
10 +sed "s/{{ADMIN_TOKEN}}/$TOKEN/g" deploy/satelliteindex.mld.json > deploy/rendered/satelliteindex.json
11 +python3 -c "import json,sys; json.load(open('deploy/rendered/satelliteindex.json')); print('manifest ok')"
12 +if [ "${1:-}" = "--push" ]; then
13 + scp -q deploy/rendered/satelliteindex.json M1M32:~/dispatch/apps/satelliteindex.json
14 + ssh M1M32 'chmod 600 ~/dispatch/apps/satelliteindex.json && echo "pushed to M1M32:~/dispatch/apps/satelliteindex.json"'
15 +fi
added deploy/satelliteindex.mld.json +113 −0
@@ -0,0 +1,113 @@
1 +{
2 + "app": "satelliteindex",
3 + "label": "SatelliteIndex — the world's orbital infrastructure, mapped and indexed",
4 + "domain": "www.satelliteindex.io",
5 + "port": 8310,
6 + "health_path": "/api/v1/health",
7 + "dir": "~/apps/satelliteindex",
8 + "extra_paths": [],
9 + "sync_excludes": [
10 + ".venv/", "__pycache__/", ".pytest_cache/", ".ruff_cache/", "*.egg-info/", ".git/", ".env", ".env.*", "!.env.example",
11 + "node_modules/", "apps/web/.next/", "apps/web/next-env.d.ts", "*.tsbuildinfo", "apps/web/qa/screens/",
12 + "/data/", "/tmp/", "logs/", ".DS_Store", ".claude/", "deploy/.admin-token", "deploy/rendered/"
13 + ],
14 + "requires": {
15 + "runtimes": ["pm2", "node", "pnpm", "uv", "uv-python@3.12"],
16 + "ram_gb": 6,
17 + "ports": [8310, 8311]
18 + },
19 + "ram_mb_observed": 2200,
20 + "size_mb": 40,
21 + "placement": {
22 + "pin": "M2M32b",
23 + "prefer": null,
24 + "avoid": ["M3U96b", "M1M32"],
25 + "reason": "Mac Studio M2 Max 12 c / 32 Go dédié (aucune autre app), Postgres 17 + Redis Homebrew installés le 2026-09-11, raccordé au tunnel wg1 (10.67.0.19)"
26 + },
27 + "processes": [
28 + {
29 + "name": "satelliteindex-api",
30 + "manager": "pm2",
31 + "script": "{{HOME}}/apps/satelliteindex/.venv/bin/python",
32 + "args": ["-m", "uvicorn", "satelliteindex.api.main:app", "--host", "127.0.0.1", "--port", "8311", "--no-access-log", "--proxy-headers", "--timeout-keep-alive", "75", "--workers", "2"],
33 + "interpreter": null,
34 + "cwd": "{{HOME}}/apps/satelliteindex",
35 + "env": {
36 + "APP_ENV": "production",
37 + "APP_DOMAIN": "www.satelliteindex.io",
38 + "SI_SITE_URL": "https://www.satelliteindex.io",
39 + "DATABASE_URL": "postgresql+asyncpg://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex",
40 + "REDIS_URL": "redis://127.0.0.1:6379/4",
41 + "SI_DATA_DIR": "{{HOME}}/satelliteindex-data",
42 + "SI_API_HOST": "127.0.0.1",
43 + "SI_API_PORT": "8311",
44 + "SI_ADMIN_TOKEN": "{{ADMIN_TOKEN}}",
45 + "SI_LOG_JSON": "1",
46 + "PYTHONUNBUFFERED": "1"
47 + },
48 + "cron_restart": null,
49 + "autorestart": true,
50 + "max_memory_restart": "3G"
51 + },
52 + {
53 + "name": "satelliteindex-scheduler",
54 + "manager": "pm2",
55 + "script": "{{HOME}}/apps/satelliteindex/.venv/bin/si",
56 + "args": ["schedule"],
57 + "interpreter": null,
58 + "cwd": "{{HOME}}/apps/satelliteindex",
59 + "env": {
60 + "APP_ENV": "production",
61 + "SI_SITE_URL": "https://www.satelliteindex.io",
62 + "DATABASE_URL": "postgresql+asyncpg://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex",
63 + "REDIS_URL": "redis://127.0.0.1:6379/4",
64 + "SI_DATA_DIR": "{{HOME}}/satelliteindex-data",
65 + "SI_ADMIN_TOKEN": "{{ADMIN_TOKEN}}",
66 + "SI_LOG_JSON": "1",
67 + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin",
68 + "PYTHONUNBUFFERED": "1"
69 + },
70 + "cron_restart": null,
71 + "autorestart": true,
72 + "max_memory_restart": "4G"
73 + },
74 + {
75 + "name": "satelliteindex-web",
76 + "manager": "pm2",
77 + "script": "/opt/homebrew/bin/node",
78 + "args": ["node_modules/next/dist/bin/next", "start", "-p", "8310", "-H", "0.0.0.0"],
79 + "interpreter": null,
80 + "cwd": "{{HOME}}/apps/satelliteindex/apps/web",
81 + "env": {
82 + "NODE_ENV": "production",
83 + "API_URL": "http://127.0.0.1:8311",
84 + "NEXT_PUBLIC_SITE_URL": "https://www.satelliteindex.io",
85 + "SI_ADMIN_TOKEN": "{{ADMIN_TOKEN}}",
86 + "NEXT_TELEMETRY_DISABLED": "1"
87 + },
88 + "cron_restart": null,
89 + "autorestart": true,
90 + "max_memory_restart": "2G"
91 + }
92 + ],
93 + "ngrok": null,
94 + "launchd": [],
95 + "env_overrides": {},
96 + "hooks": {
97 + "post_sync": [
98 + "mkdir -p $HOME/satelliteindex-data/{raw,logs,backups,cache} && echo ' data dirs ok'",
99 + "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' python deps ok'",
100 + "export PATH=\"/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH\"; DATABASE_URL=postgresql+asyncpg://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex SI_DATA_DIR=$HOME/satelliteindex-data SI_LOG_JSON=0 .venv/bin/si migrate 2>&1 | tail -2 && DATABASE_URL=postgresql+asyncpg://satelliteindex:satelliteindex@127.0.0.1:5432/satelliteindex SI_DATA_DIR=$HOME/satelliteindex-data SI_LOG_JSON=0 .venv/bin/si seed | tail -1 && echo ' migrate + seed ok'",
101 + "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' web deps ok'",
102 + "export PATH=\"/opt/homebrew/bin:$PATH\"; cd apps/web && API_URL=http://127.0.0.1:8311 NEXT_PUBLIC_SITE_URL=https://www.satelliteindex.io NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'"
103 + ],
104 + "post_start": []
105 + },
106 + "notes": "v0.1.0 (2026-09-11) : Next 16 :8310 (rewrites /api/v1/* → FastAPI 127.0.0.1:8311, 2 workers uvicorn), scheduler APScheduler (CelesTrak GP 2 h, groupes 2 h, SATCAT 24 h, analytics dérivées 1 h, backup pg_dump 04:40) ; Postgres 17 + Redis Homebrew locaux ; données hors repo ~/satelliteindex-data (raw gzip, backups, logs). Secret SI_ADMIN_TOKEN uniquement ici (laptop : deploy/.admin-token). Après le premier deploy : bash deploy/first-run.sh (ingestion initiale).",
107 + "tunnel": {
108 + "domain": "www.satelliteindex.io",
109 + "gateway": "BHS64",
110 + "redirects": [],
111 + "note": "DNS A www → 51.161.112.61 posé par l'utilisateur le 2026-09-11 ; apex satelliteindex.io sans A record (ajouter A @ → 51.161.112.61 puis redirects: [\"satelliteindex.io\"])"
112 + }
113 +}
added docs/API.md +34 −0
@@ -0,0 +1,34 @@
1 +# SatelliteIndex API — /api/v1
2 +
3 +Base URL: `https://www.satelliteindex.io/api/v1` (OpenAPI: `/api/v1/docs`, `/api/v1/openapi.json`). Envelope `{ "data": …, "meta": { request_id, generated_at, … } }`;
4 +lists `{ "data": [], "pagination": { page, page_size, total, pages }, "meta": {} }`. Errors `{ "error": { title, detail, status }, "meta": {} }`. All times UTC ISO 8601; km, km/s, degrees, minutes.
5 +
6 +| Endpoint | Purpose |
7 +|---|---|
8 +| `GET /health`, `/ready` | service + data freshness |
9 +| `GET /satellites` | list/filter (`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, page, page_size`) |
10 +| `GET /satellites/facets` | facet counts for the same filters |
11 +| `GET /satellites/{slug|norad|id}` | detail: orbital_state, live position, aliases, tags, identifiers, history, memberships, provenance, events, quality flags, siblings, related, sources, freshness |
12 +| `GET /satellites/{id}/orbit` | element-set history (append-only) |
13 +| `GET /satellites/{id}/position?time=` | SGP4 position (lat, lon, altitude_km, velocity_km_s, source_epoch) |
14 +| `GET /satellites/{id}/track?before=45&after=90&step=60` | ground track |
15 +| `GET /satellites/{id}/history` | change log + daily altitude series |
16 +| `GET /satellites/{id}/live` | position from the shared in-memory propagator |
17 +| `GET /orbit/positions?step=30` | compact batch for the globe: `norad[]`, `cls[]`, `mission[]`, `active[]`, `pos[]` (lat0,lon0,alt0,lat1,lon1,alt1 …), `vel[]` |
18 +| `GET /orbit/density` | altitude buckets, LEO 25 km profile, inclination profile |
19 +| `GET /search?q=&limit=&types=` · `/search/suggest?q=` | unified search (satellites, operators, constellations, countries, launches, sites) |
20 +| `GET /stats` · `/stats/home` | global statistics snapshot (hourly) |
21 +| `GET /rankings?metric=constellations|operators|countries|countries-debris|launches|fastest-growing|congested-shells|launch-years` | rankings |
22 +| `GET /constellations` · `/constellations/{slug}` | constellations + deployment analytics |
23 +| `GET /operators` · `/operators/{slug}` | organizations |
24 +| `GET /countries` · `/countries/{code|slug}` | countries |
25 +| `GET /launches` · `/launches/timeline` · `/launches/{cospar}` | launches (derived from international designators) |
26 +| `GET /launch-sites` · `/launch-sites/{slug}` | launch sites |
27 +| `GET /debris` · `/reentries` | debris explorer · decays + low-perigee watch (not a prediction) |
28 +| `GET /events` · `/events/{id}` | derived event feed |
29 +| `GET /sources` · `/sources/status` · `/methodology` | transparency |
30 +| `POST /views` | anonymous page-view beacon `{entity_type, entity_id}` |
31 +| `/admin/*` | token-protected (`x-si-admin-token`) |
32 +
33 +Rate limits per IP per minute: default 600, search 120, positions 60, position/track 240, admin 120.
34 +Attribution: orbital data courtesy of CelesTrak. Not for operational use.
added docs/DEPLOY.md +58 −0
@@ -0,0 +1,58 @@
1 +# Deploying SatelliteIndex on MacLustr
2 +
3 +Production runs on **M2M32b** (Mac Studio M2 Max, 12 c / 32 GB, dedicated) behind the MacLustr Tunnel (BHS64 Caddy → wg1 10.67.0.19).
4 +Everything goes through the gateway M1M32 and `mld` (see `~/Desktop/cluster-skill/mld`).
5 +
6 +## Topology
7 +
8 +```
9 +Internet → GoDaddy DNS (A www.satelliteindex.io → 51.161.112.61)
10 + → BHS64 Caddy (TLS Let's Encrypt, HTTP→HTTPS) → WireGuard wg1 → M2M32b:8310 (Next.js)
11 + └─ rewrite /api/v1/* → 127.0.0.1:8311 (FastAPI, 2 uvicorn workers)
12 + └─ satelliteindex-scheduler (si schedule: connectors + backups)
13 + └─ Postgres 17 (Homebrew, db satelliteindex) · Redis db 4
14 +```
15 +
16 +Node prerequisites (done 2026-09-11 by `mld prepare M2M32b` + Homebrew): node 25, pnpm, pm2 (LaunchAgent), uv + Python 3.12,
17 +`postgresql@17` and `redis` as `brew services`, role/db `satelliteindex` (password `satelliteindex`, localhost only), extensions `pg_trgm`, `uuid-ossp`.
18 +
19 +## Release procedure (from the laptop)
20 +
21 +```bash
22 +cd ~/Desktop/Projets/apps-web/satelliteindex
23 +pytest -q && (cd apps/web && pnpm typecheck) # 1. tests + typecheck
24 +deploy/render-manifest.sh --push # 2. manifest with the real SI_ADMIN_TOKEN → M1M32:~/dispatch/apps/satelliteindex.json
25 +~/Desktop/cluster-skill/mld stage ~/Desktop/Projets/apps-web/satelliteindex satelliteindex # 3. laptop → gateway staging (respects sync_excludes)
26 +~/Desktop/cluster-skill/mld deploy satelliteindex --node M2M32b # 4. rsync → node, post_sync hooks (venv, migrate, seed, pnpm build), PM2, health, tunnel route, registry
27 +ssh M2M32b 'cd ~/apps/satelliteindex && bash deploy/first-run.sh' # 5. first time only: initial ingestion (SATCAT, GP, groups, analytics)
28 +curl -sI https://www.satelliteindex.io | head -1 # 6. public check
29 +```
30 +
31 +`mld deploy` re-points the Caddy route `https://www.satelliteindex.io → M2M32b:8310` on BHS64 automatically and runs the public
32 +health check (`/api/v1/health`). `mld heal` (every 5 min on M1M32) restarts anything missing after a power cut.
33 +
34 +Secrets: only `SI_ADMIN_TOKEN` (laptop copy `deploy/.admin-token`, git-ignored; rendered manifest in `deploy/rendered/`, git-ignored).
35 +Never commit `.env`.
36 +
37 +## Operations
38 +
39 +```bash
40 +~/Desktop/cluster-skill/mld status | grep satelliteindex # where / online
41 +~/Desktop/cluster-skill/mld logs satelliteindex # PM2 logs
42 +ssh M2M32b 'cd ~/apps/satelliteindex && SI_LOG_JSON=0 .venv/bin/si status' # connector table
43 +ssh M2M32b 'cd ~/apps/satelliteindex && .venv/bin/si run celestrak_gp --force' # manual run (or /admin → Run now)
44 +ssh M2M32b 'cd ~/apps/satelliteindex && .venv/bin/si backup' # pg_dump → ~/satelliteindex-data/backups (also nightly 04:40 by the scheduler)
45 +```
46 +
47 +Restore: `pg_restore -d satelliteindex_restore --clean --if-exists <dump>` into a fresh database, then point `DATABASE_URL` at it.
48 +Off-node copies of the nightly dumps: `scripts/backup-offnode.sh` (rsync to M1M32:~/backups/satelliteindex/).
49 +
50 +## Apex domain
51 +
52 +`satelliteindex.io` has no A record yet. When `A @ → 51.161.112.61` exists at GoDaddy, set `"redirects": ["satelliteindex.io"]` in the
53 +manifest tunnel block, `deploy/render-manifest.sh --push`, then `mld tunnel route satelliteindex` — Caddy will redirect the apex to www.
54 +
55 +## Scaling later
56 +
57 +- A second node can run `satelliteindex-scheduler` / workers: connector runs are guarded by Redis locks (`si:lock:connector:<name>`).
58 +- Workers on Linux (OVH) are possible: the backend has no macOS dependency; Docker files are not provided yet (PM2 is the MacLustr standard).
added docs/FRONTEND-GUIDE.md +65 −0
@@ -0,0 +1,65 @@
1 +# SatelliteIndex web — frontend guide (apps/web)
2 +
3 +Next 16 (App Router, React 19, TypeScript strict, Tailwind v4). Server components by default; client components only for
4 +interactivity (`'use client'`). The FastAPI backend runs on `http://127.0.0.1:8311`; the browser talks to the same origin
5 +(`/api/v1/*` is rewritten to the API in `next.config.ts`).
6 +
7 +## Data access
8 +
9 +- **Server components**: `import { api, safe, ApiError } from '@/lib/api'` — typed wrappers for every endpoint (see `src/lib/api.ts`,
10 + types in `src/lib/types.ts`). `api.x()` throws `ApiError` (`.notFound`, `.unavailable`). Use `notFound()` from `next/navigation`
11 + on 404. Wrap optional panels with `safe()` and render `<Unavailable/>` when null. **Never crash a page because one panel failed.**
12 +- **Client components**: `import { clientApi } from '@/lib/client-api'` (positions, live position, track, search, view beacon).
13 +- Inspect any payload live: `curl -s localhost:8311/api/v1/<path> | python3 -m json.tool | head -80`. OpenAPI: `localhost:8311/api/v1/docs`.
14 +- Postgres aggregates can arrive as **strings** (`Num = number | string | null`): always go through `num()` / `fmtInt()` etc. from `@/lib/format`.
15 +- **No hardcoded statistics, counts, timestamps or fake data.** If something is missing, show "Unavailable" (`<Unavailable what="…"/>`).
16 +
17 +## Design system (dark, scientific, premium — not a crypto dashboard, not generic SaaS cards)
18 +
19 +Tokens in `src/app/globals.css` → Tailwind utilities: `bg-space | bg-plane | bg-plane-2 | bg-plane-3`, `text-ink | text-ink-2 | text-ink-3`,
20 +`border-rule | border-rule-strong`, `text-accent | bg-accent | text-accent-ink | bg-accent-soft`, `text-accent-2`, status
21 +`text-active | text-warn | text-danger | text-inactive` (+ `-soft` backgrounds), orbit classes `text-leo | text-meo | text-geo | text-heo | text-other`,
22 +chart series `series-1..8`. Utility classes: `.panel` (translucent bordered surface — use sparingly, prefer spatial composition and hairlines),
23 +`.eyebrow` (small caps label), `.display` (big headline), `.mono` / `.tnum` (telemetry, IDs, numbers), `.container-x`,
24 +`.data-table` (+ `.stack` to transform rows into stacked cards under 768 px — put `data-label="…"` on each `<td>`, `className="primary"` on the name cell, `.num` for numbers),
25 +`.grid-bg`, `.dot` / `.pulse`, `.link`, `.scrollbar-thin`, `.no-scrollbar`.
26 +
27 +Shared components (do **not** modify; build local ones in your own folder if you need variants):
28 +- `@/components/ui/section` → `Container`, `PageHeader`, `Section` (eyebrow/title/action), `Stat` (big number tile)
29 +- `@/components/ui/badges` → `StatusBadge`, `OrbitBadge`, `TypeBadge`, `MissionLabel`, `FreshnessBadge`
30 +- `@/components/ui/pagination` → `Pagination` (URL-driven), `@/components/ui/unavailable` → `Unavailable`
31 +- `@/components/charts/charts` → `HBars`, `Bars`, `StackedBars`, `AreaChart`, `Donut`, `Sparkline`, `Histogram` (pure SVG, server-safe)
32 +- `@/components/map/world-map` → `WorldMap` (SVG equirectangular; `tracks`, `markers`; `splitTrack` for antimeridian), `MAP_SIZE`
33 +- `@/lib/site` → `routes.*`, `SITE_NAME/SITE_URL/TAGLINE`, `ORBIT_CLASS_COLORS`, `STATUS_COLORS`, `MISSION_LABELS`, `OBJECT_TYPE_LABELS`, `EVENT_TYPE_LABELS`
34 +- `@/lib/format` → `fmtInt fmt1 fmt2 fmtCompact fmtKm fmtDeg fmtMinutes fmtPct fmtDate fmtDateTime fmtAgo fmtYear titleCase num`
35 +- Layout (header, mobile tab bar, footer, ⌘K search dialog) is already mounted in `src/app/layout.tsx`. Pages render inside `<main>`
36 + **without** padding: wrap content in `<Container>` (max 1280) or `<Container wide>` (1600), or go full-bleed on purpose (globe).
37 +
38 +Typography: Inter (UI) + JetBrains Mono (IDs/telemetry). Big display numbers use `tnum`. Icons: `lucide-react`.
39 +
40 +## Mobile rules (mandatory — every feature is tested at 390 px before it is complete)
41 +
42 +- No horizontal overflow at 320–430 px. Tables → `.data-table.stack` or purpose-built list rows. Long IDs wrap or truncate.
43 +- Tap targets ≥ 44 px. The fixed bottom tab bar (58 px + safe area) is reserved by `<body>` padding; don't add another fixed bottom bar.
44 +- Desktop should use wide screens (two-column "terminal" layouts on detail pages: main visualisation + right telemetry panel).
45 +- DOM order = visual order on all breakpoints (no `order:` tricks). Heavy globe/3D features lazy-load (`next/dynamic`, `ssr: false`).
46 +
47 +## SEO & sharing
48 +
49 +Every public page exports `generateMetadata` (title, description, `alternates.canonical`, `openGraph`, `twitter`). Detail pages should
50 +add JSON-LD where sensible (`<script type="application/ld+json">`). Titles follow `ISS (ZARYA) — Live Orbit, NORAD 25544 | SatelliteIndex`
51 +(the layout template appends `| SatelliteIndex` automatically: pass only the first part).
52 +
53 +## Freshness & sources (transparency is a feature)
54 +
55 +Show "Orbit updated X ago · epoch age" and "Metadata updated X ago" using `fmtAgo`; expose a **Sources** section on detail pages
56 +(`detail.sources`, `detail.provenance`, `detail.freshness`). Derived values (orbit class, mission type, constellation membership,
57 +activity score) must be labelled *derived* with a link to `/methodology`.
58 +
59 +## Verification before you report
60 +
61 +1. `cd apps/web && pnpm typecheck` must pass with zero errors (fix yours; if a shared type is wrong, say so in your report).
62 +2. The dev server already runs at `http://localhost:8310` (hot reload). Load each page you built at 390 px and 1440 px with Playwright:
63 + `node -e "…"` importing `/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs` (chromium), take screenshots into
64 + `apps/web/qa/screens/<your-area>/`, and check `document.documentElement.scrollWidth <= innerWidth` and zero console errors.
65 +3. Do **not** run `next build` and do not start another dev server (port 8310 is taken). Do not edit files outside your ownership list.
added migrations/env.py +45 −0
@@ -0,0 +1,45 @@
1 +"""Alembic environment (async engine). Migrations are plain SQL executed through `op.execute`."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +from logging.config import fileConfig
6 +
7 +from alembic import context
8 +from sqlalchemy.ext.asyncio import create_async_engine
9 +
10 +from satelliteindex.config import settings
11 +
12 +config = context.config
13 +if config.config_file_name is not None:
14 + fileConfig(config.config_file_name)
15 +
16 +target_metadata = None
17 +
18 +
19 +def run_migrations_offline() -> None:
20 + context.configure(url=settings.sync_database_url, literal_binds=True, dialect_opts={"paramstyle": "named"})
21 + with context.begin_transaction():
22 + context.run_migrations()
23 +
24 +
25 +def do_run_migrations(connection) -> None: # type: ignore[no-untyped-def]
26 + context.configure(connection=connection, target_metadata=target_metadata, transaction_per_migration=True)
27 + with context.begin_transaction():
28 + context.run_migrations()
29 +
30 +
31 +async def run_async_migrations() -> None:
32 + engine = create_async_engine(settings.database_url, poolclass=None)
33 + async with engine.connect() as connection:
34 + await connection.run_sync(do_run_migrations)
35 + await engine.dispose()
36 +
37 +
38 +def run_migrations_online() -> None:
39 + asyncio.run(run_async_migrations())
40 +
41 +
42 +if context.is_offline_mode():
43 + run_migrations_offline()
44 +else:
45 + run_migrations_online()
added migrations/script.py.mako +22 −0
@@ -0,0 +1,22 @@
1 +"""${message}
2 +
3 +Revision ID: ${up_revision}
4 +Revises: ${down_revision | comma,n}
5 +Create Date: ${create_date}
6 +"""
7 +from __future__ import annotations
8 +
9 +from alembic import op
10 +
11 +revision = ${repr(up_revision)}
12 +down_revision = ${repr(down_revision)}
13 +branch_labels = ${repr(branch_labels)}
14 +depends_on = ${repr(depends_on)}
15 +
16 +
17 +def upgrade() -> None:
18 + ${upgrades if upgrades else "pass"}
19 +
20 +
21 +def downgrade() -> None:
22 + ${downgrades if downgrades else "pass"}
added migrations/versions/0001_initial.py +594 −0
@@ -0,0 +1,594 @@
1 +"""initial canonical schema
2 +
3 +Revision ID: 0001
4 +Revises:
5 +Create Date: 2026-09-11
6 +"""
7 +from __future__ import annotations
8 +
9 +from alembic import op
10 +
11 +revision = "0001"
12 +down_revision = None
13 +branch_labels = None
14 +depends_on = None
15 +
16 +SQL = r"""
17 +create extension if not exists pg_trgm;
18 +create extension if not exists "uuid-ossp";
19 +
20 +-- ---------------------------------------------------------------- sources & connector observability
21 +create table sources (
22 + id text primary key, -- e.g. 'celestrak'
23 + name text not null,
24 + type text not null, -- orbital | catalog | registry | regulatory | company | news | weather
25 + base_url text,
26 + official boolean not null default false,
27 + country_code text,
28 + authority_type text, -- government | intergovernmental | scientific | operator | secondary
29 + license text,
30 + attribution_required boolean not null default true,
31 + attribution_text text,
32 + update_frequency_seconds integer,
33 + enabled boolean not null default true,
34 + priority integer not null default 100,
35 + created_at timestamptz not null default now(),
36 + updated_at timestamptz not null default now()
37 +);
38 +
39 +create table connectors (
40 + name text primary key, -- e.g. 'celestrak_gp'
41 + source_id text not null references sources(id),
42 + description text,
43 + interval_seconds integer not null,
44 + enabled boolean not null default true,
45 + priority integer not null default 100,
46 + config jsonb not null default '{}'::jsonb,
47 + consecutive_failures integer not null default 0,
48 + circuit_open_until timestamptz,
49 + last_success_at timestamptz,
50 + last_attempt_at timestamptz,
51 + last_duration_ms integer,
52 + next_run_at timestamptz,
53 + created_at timestamptz not null default now(),
54 + updated_at timestamptz not null default now()
55 +);
56 +
57 +create table connector_runs (
58 + id text primary key,
59 + connector_name text not null references connectors(name),
60 + source_id text not null references sources(id),
61 + started_at timestamptz not null default now(),
62 + finished_at timestamptz,
63 + status text not null default 'running', -- running | success | unchanged | failed | skipped
64 + duration_ms integer,
65 + records_fetched integer not null default 0,
66 + records_created integer not null default 0,
67 + records_updated integer not null default 0,
68 + records_skipped integer not null default 0,
69 + error text,
70 + payload_hash text,
71 + meta jsonb not null default '{}'::jsonb
72 +);
73 +create index connector_runs_name_started_idx on connector_runs (connector_name, started_at desc);
74 +
75 +create table connector_errors (
76 + id bigserial primary key,
77 + connector_name text not null references connectors(name),
78 + run_id text references connector_runs(id),
79 + occurred_at timestamptz not null default now(),
80 + error_type text,
81 + message text not null,
82 + context jsonb
83 +);
84 +
85 +create table raw_records (
86 + id text primary key,
87 + source_id text not null references sources(id),
88 + connector_name text not null,
89 + run_id text references connector_runs(id),
90 + source_native_id text, -- group name, file name, query…
91 + content_type text not null,
92 + payload_hash text not null,
93 + byte_size bigint not null default 0,
94 + storage_path text, -- relative to SI_DATA_DIR/raw
95 + source_url text,
96 + fetched_at timestamptz not null default now(),
97 + processed_at timestamptz,
98 + processing_status text not null default 'pending', -- pending | processed | failed | unchanged
99 + record_count integer,
100 + error text
101 +);
102 +create index raw_records_source_fetched_idx on raw_records (source_id, fetched_at desc);
103 +create index raw_records_hash_idx on raw_records (payload_hash);
104 +
105 +-- ---------------------------------------------------------------- reference: countries, owners, launch sites
106 +create table countries (
107 + code text primary key, -- ISO 3166-1 alpha-2
108 + iso3 text,
109 + name text not null,
110 + slug text not null unique,
111 + region text,
112 + flag text
113 +);
114 +
115 +create table owner_codes (
116 + code text primary key, -- SATCAT owner code (US, PRC, CIS, SES, ESA…)
117 + name text not null,
118 + kind text not null, -- country | organization | intergovernmental | consortium | unknown
119 + country_code text references countries(code),
120 + organization_id text
121 +);
122 +
123 +create table launch_sites (
124 + code text primary key, -- SATCAT launch site code
125 + name text not null,
126 + slug text not null unique,
127 + country_code text references countries(code),
128 + latitude double precision,
129 + longitude double precision,
130 + active boolean not null default true
131 +);
132 +
133 +-- ---------------------------------------------------------------- organizations & constellations
134 +create table organizations (
135 + id text primary key,
136 + slug text not null unique,
137 + name text not null,
138 + normalized_name text not null,
139 + kind text not null default 'operator', -- operator | manufacturer | agency | military | launch_provider | consortium
140 + country_code text references countries(code),
141 + official_url text,
142 + description text,
143 + founded_year integer,
144 + created_at timestamptz not null default now(),
145 + updated_at timestamptz not null default now()
146 +);
147 +create index organizations_normalized_idx on organizations (normalized_name);
148 +create index organizations_trgm_idx on organizations using gin (name gin_trgm_ops);
149 +
150 +create table organization_aliases (
151 + id bigserial primary key,
152 + organization_id text not null references organizations(id) on delete cascade,
153 + alias text not null,
154 + normalized text not null,
155 + source_id text references sources(id),
156 + unique (organization_id, normalized)
157 +);
158 +create index organization_aliases_normalized_idx on organization_aliases (normalized);
159 +
160 +create table constellations (
161 + id text primary key,
162 + slug text not null unique,
163 + name text not null,
164 + operator_id text references organizations(id),
165 + country_code text references countries(code),
166 + service_type text, -- communications | earth-observation | navigation | iot | weather | science | military | technology
167 + orbit_class text, -- LEO | MEO | GEO | HEO | MIXED
168 + lifecycle_stage text not null default 'OPERATIONAL',
169 + description text,
170 + official_url text,
171 + planned_count integer,
172 + authorized_count integer,
173 + match_patterns jsonb not null default '[]'::jsonb,
174 + celestrak_groups jsonb not null default '[]'::jsonb,
175 + created_at timestamptz not null default now(),
176 + updated_at timestamptz not null default now()
177 +);
178 +
179 +-- ---------------------------------------------------------------- launches
180 +create table launch_vehicle_families (
181 + id text primary key, slug text not null unique, name text not null, manufacturer_id text references organizations(id), country_code text references countries(code)
182 +);
183 +create table launch_vehicles (
184 + id text primary key, slug text not null unique, name text not null, family_id text references launch_vehicle_families(id), variant text
185 +);
186 +
187 +create table launches (
188 + id text primary key,
189 + cospar_launch_id text not null unique, -- '1998-067'
190 + launch_date date,
191 + launch_year integer,
192 + launch_site_code text references launch_sites(code),
193 + launch_vehicle_id text references launch_vehicles(id),
194 + provider_id text references organizations(id),
195 + owner_codes text[] not null default '{}',
196 + payload_count integer not null default 0,
197 + object_count integer not null default 0,
198 + on_orbit_count integer not null default 0,
199 + primary_name text,
200 + first_seen_at timestamptz not null default now(),
201 + updated_at timestamptz not null default now()
202 +);
203 +create index launches_date_idx on launches (launch_date desc);
204 +create index launches_site_idx on launches (launch_site_code);
205 +
206 +-- ---------------------------------------------------------------- satellites (canonical objects)
207 +create table satellites (
208 + id text primary key,
209 + slug text not null unique,
210 + canonical_name text not null,
211 + normalized_name text not null,
212 + norad_id integer unique,
213 + cospar_id text,
214 + object_type text not null default 'UNKNOWN', -- PAYLOAD | ROCKET_BODY | DEBRIS | UNKNOWN | STATION | CREWED
215 + status text not null default 'UNKNOWN', -- ACTIVE | INACTIVE | DECAYED | LOST | FAILED | UNKNOWN | PLANNED
216 + ops_status_code text, -- SATCAT: + P B S X D ?
217 + operator_id text references organizations(id),
218 + owner_code text references owner_codes(code),
219 + country_code text references countries(code),
220 + constellation_id text references constellations(id),
221 + launch_id text references launches(id),
222 + launch_date date,
223 + launch_site_code text references launch_sites(code),
224 + decay_date date,
225 + mission_type text, -- communications | earth-observation | navigation | weather | science | military | technology | station | unknown
226 + orbit_class text, -- LEO | MEO | GEO | HEO | OTHER
227 + period_minutes double precision,
228 + inclination_deg double precision,
229 + apogee_km double precision,
230 + perigee_km double precision,
231 + rcs_m2 double precision,
232 + orbit_center text,
233 + orbit_type text,
234 + has_gp boolean not null default false,
235 + latest_epoch timestamptz,
236 + mass_kg double precision,
237 + description text,
238 + official_url text,
239 + first_seen_at timestamptz not null default now(),
240 + last_seen_at timestamptz not null default now(),
241 + created_at timestamptz not null default now(),
242 + updated_at timestamptz not null default now()
243 +);
244 +create index satellites_cospar_idx on satellites (cospar_id);
245 +create index satellites_status_idx on satellites (status);
246 +create index satellites_type_status_idx on satellites (object_type, status);
247 +create index satellites_constellation_idx on satellites (constellation_id);
248 +create index satellites_operator_idx on satellites (operator_id);
249 +create index satellites_country_idx on satellites (country_code);
250 +create index satellites_launch_idx on satellites (launch_id);
251 +create index satellites_launch_date_idx on satellites (launch_date desc);
252 +create index satellites_decay_date_idx on satellites (decay_date desc);
253 +create index satellites_orbit_class_idx on satellites (orbit_class);
254 +create index satellites_name_trgm_idx on satellites using gin (canonical_name gin_trgm_ops);
255 +create index satellites_norad_text_idx on satellites ((norad_id::text) text_pattern_ops);
256 +
257 +create table satellite_aliases (
258 + id bigserial primary key,
259 + satellite_id text not null references satellites(id) on delete cascade,
260 + alias text not null,
261 + normalized text not null,
262 + source_id text references sources(id),
263 + unique (satellite_id, normalized)
264 +);
265 +create index satellite_aliases_normalized_idx on satellite_aliases (normalized);
266 +
267 +create table satellite_slugs (
268 + slug text primary key,
269 + satellite_id text not null references satellites(id) on delete cascade,
270 + created_at timestamptz not null default now()
271 +);
272 +
273 +create table satellite_tags (
274 + satellite_id text not null references satellites(id) on delete cascade,
275 + tag text not null, -- celestrak group name or derived tag
276 + source_id text references sources(id),
277 + first_seen_at timestamptz not null default now(),
278 + last_seen_at timestamptz not null default now(),
279 + primary key (satellite_id, tag)
280 +);
281 +create index satellite_tags_tag_idx on satellite_tags (tag);
282 +
283 +create table satellite_status_history (
284 + id bigserial primary key,
285 + satellite_id text not null references satellites(id) on delete cascade,
286 + field text not null,
287 + old_value text,
288 + new_value text,
289 + source_id text references sources(id),
290 + changed_at timestamptz not null default now()
291 +);
292 +create index satellite_status_history_sat_idx on satellite_status_history (satellite_id, changed_at desc);
293 +create index satellite_status_history_changed_idx on satellite_status_history (changed_at desc);
294 +
295 +create table constellation_memberships (
296 + id bigserial primary key,
297 + satellite_id text not null references satellites(id) on delete cascade,
298 + constellation_id text not null references constellations(id) on delete cascade,
299 + method text not null, -- celestrak_group | name_pattern | manual
300 + since timestamptz not null default now(),
301 + until timestamptz
302 +);
303 +create index constellation_memberships_sat_idx on constellation_memberships (satellite_id) where until is null;
304 +
305 +-- ---------------------------------------------------------------- orbital history (append only)
306 +create table orbital_elements (
307 + id bigserial primary key,
308 + satellite_id text not null references satellites(id) on delete cascade,
309 + source_id text not null references sources(id),
310 + epoch timestamptz not null,
311 + mean_motion double precision not null,
312 + eccentricity double precision not null,
313 + inclination double precision not null,
314 + raan double precision not null,
315 + arg_of_perigee double precision not null,
316 + mean_anomaly double precision not null,
317 + bstar double precision,
318 + mean_motion_dot double precision,
319 + mean_motion_ddot double precision,
320 + element_set_no integer,
321 + rev_at_epoch integer,
322 + classification text,
323 + ephemeris_type integer,
324 + semi_major_axis_km double precision,
325 + perigee_km double precision,
326 + apogee_km double precision,
327 + period_minutes double precision,
328 + element_format text not null default 'omm_json',
329 + raw_omm jsonb,
330 + received_at timestamptz not null default now(),
331 + created_at timestamptz not null default now(),
332 + unique (satellite_id, source_id, epoch)
333 +);
334 +create index orbital_elements_sat_epoch_idx on orbital_elements (satellite_id, epoch desc);
335 +create index orbital_elements_received_idx on orbital_elements (received_at desc);
336 +
337 +-- latest element set per satellite (kept in sync by the connector; avoids DISTINCT ON over history)
338 +create table orbital_state (
339 + satellite_id text primary key references satellites(id) on delete cascade,
340 + element_id bigint not null references orbital_elements(id),
341 + source_id text not null references sources(id),
342 + epoch timestamptz not null,
343 + mean_motion double precision not null,
344 + eccentricity double precision not null,
345 + inclination double precision not null,
346 + raan double precision not null,
347 + arg_of_perigee double precision not null,
348 + mean_anomaly double precision not null,
349 + bstar double precision,
350 + mean_motion_dot double precision,
351 + mean_motion_ddot double precision,
352 + semi_major_axis_km double precision,
353 + perigee_km double precision,
354 + apogee_km double precision,
355 + period_minutes double precision,
356 + orbit_class text,
357 + updated_at timestamptz not null default now()
358 +);
359 +create index orbital_state_epoch_idx on orbital_state (epoch);
360 +create index orbital_state_class_idx on orbital_state (orbit_class);
361 +
362 +-- ---------------------------------------------------------------- identifiers, provenance, quality
363 +create table entity_identifiers (
364 + id bigserial primary key,
365 + entity_type text not null,
366 + entity_id text not null,
367 + source_id text references sources(id),
368 + identifier_type text not null, -- norad | cospar | jcat | un_registration | fcc | itu | source_native_id | launch_id
369 + identifier_value text not null,
370 + confidence double precision not null default 1.0,
371 + first_seen_at timestamptz not null default now(),
372 + last_seen_at timestamptz not null default now(),
373 + verified boolean not null default false,
374 + metadata jsonb,
375 + unique (entity_type, entity_id, identifier_type, identifier_value)
376 +);
377 +create index entity_identifiers_lookup_idx on entity_identifiers (identifier_type, identifier_value);
378 +
379 +create table field_provenance (
380 + id bigserial primary key,
381 + entity_type text not null,
382 + entity_id text not null,
383 + field_name text not null,
384 + field_value text,
385 + source_id text not null references sources(id),
386 + source_record_id text,
387 + confidence double precision not null default 1.0,
388 + observed_at timestamptz not null default now(),
389 + selected_as_canonical boolean not null default true,
390 + unique (entity_type, entity_id, field_name, source_id)
391 +);
392 +create index field_provenance_entity_idx on field_provenance (entity_type, entity_id);
393 +
394 +create table data_quality_flags (
395 + id bigserial primary key,
396 + entity_type text not null,
397 + entity_id text not null,
398 + flag text not null, -- SOURCE_CONFLICT | MISSING_ID | AMBIGUOUS_ENTITY | STALE_DATA | SUSPECT_ORBIT | UNKNOWN_OPERATOR | UNKNOWN_COUNTRY | DUPLICATE_OBJECT
399 + detail text,
400 + created_at timestamptz not null default now(),
401 + resolved_at timestamptz,
402 + unique (entity_type, entity_id, flag)
403 +);
404 +create index data_quality_flags_open_idx on data_quality_flags (flag) where resolved_at is null;
405 +
406 +create table manual_review_queue (
407 + id bigserial primary key,
408 + kind text not null, -- possible_duplicate | unknown_owner | conflict
409 + entity_a_type text, entity_a_id text, entity_b_type text, entity_b_id text,
410 + confidence double precision,
411 + detail jsonb,
412 + status text not null default 'open', -- open | merged | kept_separate | dismissed
413 + created_at timestamptz not null default now(),
414 + resolved_at timestamptz, resolved_by text
415 +);
416 +
417 +create table entity_merges (
418 + id bigserial primary key,
419 + entity_type text not null, kept_id text not null, merged_id text not null,
420 + reason text, performed_by text, performed_at timestamptz not null default now(), snapshot jsonb
421 +);
422 +
423 +-- ---------------------------------------------------------------- events
424 +create table events (
425 + id text primary key,
426 + type text not null,
427 + title text not null,
428 + summary text,
429 + event_time timestamptz not null,
430 + detected_at timestamptz not null default now(),
431 + confidence double precision not null default 1.0,
432 + source_id text references sources(id),
433 + source_url text,
434 + dedupe_key text unique,
435 + metadata jsonb not null default '{}'::jsonb,
436 + created_at timestamptz not null default now()
437 +);
438 +create index events_time_idx on events (event_time desc);
439 +create index events_type_time_idx on events (type, event_time desc);
440 +
441 +create table event_entities (
442 + event_id text not null references events(id) on delete cascade,
443 + entity_type text not null,
444 + entity_id text not null,
445 + relationship text not null default 'subject',
446 + primary key (event_id, entity_type, entity_id, relationship)
447 +);
448 +create index event_entities_entity_idx on event_entities (entity_type, entity_id);
449 +
450 +-- ---------------------------------------------------------------- search
451 +create table search_index (
452 + entity_type text not null,
453 + entity_id text not null,
454 + slug text not null,
455 + title text not null,
456 + subtitle text,
457 + keywords text not null default '',
458 + weight double precision not null default 1.0,
459 + tsv tsvector,
460 + updated_at timestamptz not null default now(),
461 + primary key (entity_type, entity_id)
462 +);
463 +create index search_index_tsv_idx on search_index using gin (tsv);
464 +create index search_index_title_trgm_idx on search_index using gin (title gin_trgm_ops);
465 +create index search_index_keywords_trgm_idx on search_index using gin (keywords gin_trgm_ops);
466 +
467 +-- ---------------------------------------------------------------- derived metrics (versioned)
468 +create table metric_definitions (
469 + key text primary key, name text not null, version text not null, methodology text not null, inputs jsonb not null default '[]'::jsonb, updated_at timestamptz not null default now()
470 +);
471 +
472 +create table stats_snapshots (
473 + key text primary key, -- 'global' | 'orbit_buckets' | …
474 + computed_at timestamptz not null default now(),
475 + payload jsonb not null
476 +);
477 +
478 +-- ---------------------------------------------------------------- trending
479 +create table page_views (
480 + day date not null, entity_type text not null, entity_id text not null, views integer not null default 0,
481 + primary key (day, entity_type, entity_id)
482 +);
483 +
484 +-- ---------------------------------------------------------------- materialized views
485 +create materialized view country_stats as
486 +select c.code, c.name, c.slug,
487 + count(s.id) filter (where s.object_type in ('PAYLOAD','STATION') and s.status = 'ACTIVE') as active_payloads,
488 + count(s.id) filter (where s.object_type in ('PAYLOAD','STATION') and s.decay_date is null) as on_orbit_payloads,
489 + count(s.id) filter (where s.object_type in ('PAYLOAD','STATION')) as total_payloads,
490 + count(s.id) filter (where s.object_type = 'DEBRIS' and s.decay_date is null) as debris_on_orbit,
491 + count(s.id) filter (where s.object_type = 'ROCKET_BODY' and s.decay_date is null) as rocket_bodies_on_orbit,
492 + count(s.id) filter (where s.decay_date is null) as objects_on_orbit,
493 + count(s.id) as total_objects,
494 + count(distinct s.launch_id) as launches,
495 + count(distinct s.operator_id) as operators,
496 + count(s.id) filter (where s.launch_date >= (current_date - interval '365 days') and s.object_type in ('PAYLOAD','STATION')) as payloads_last_365d
497 +from countries c left join satellites s on s.country_code = c.code
498 +group by c.code, c.name, c.slug;
499 +create unique index country_stats_code_idx on country_stats (code);
500 +
501 +create materialized view operator_stats as
502 +select o.id, o.slug, o.name, o.kind, o.country_code,
503 + count(s.id) filter (where s.status = 'ACTIVE' and s.object_type in ('PAYLOAD','STATION')) as active_payloads,
504 + count(s.id) filter (where s.object_type in ('PAYLOAD','STATION') and s.decay_date is null) as on_orbit_payloads,
505 + count(s.id) filter (where s.object_type in ('PAYLOAD','STATION')) as total_payloads,
506 + count(s.id) filter (where s.decay_date is not null) as decayed,
507 + count(distinct s.constellation_id) as constellations,
508 + count(distinct s.launch_id) as launches,
509 + count(s.id) filter (where s.launch_date >= (current_date - interval '365 days')) as payloads_last_365d,
510 + min(s.launch_date) as first_launch, max(s.launch_date) as last_launch
511 +from organizations o left join satellites s on s.operator_id = o.id
512 +group by o.id, o.slug, o.name, o.kind, o.country_code;
513 +create unique index operator_stats_id_idx on operator_stats (id);
514 +
515 +create materialized view constellation_stats as
516 +select k.id, k.slug, k.name, k.operator_id, k.country_code, k.service_type, k.orbit_class,
517 + count(s.id) filter (where s.status = 'ACTIVE') as active,
518 + count(s.id) filter (where s.status = 'INACTIVE') as inactive,
519 + count(s.id) filter (where s.decay_date is not null) as decayed,
520 + count(s.id) filter (where s.decay_date is null) as on_orbit,
521 + count(s.id) as total,
522 + count(s.id) filter (where s.launch_date >= (current_date - interval '365 days')) as launched_last_365d,
523 + count(s.id) filter (where s.launch_date >= (current_date - interval '30 days')) as launched_last_30d,
524 + count(distinct s.launch_id) as launches,
525 + min(s.launch_date) as first_launch, max(s.launch_date) as last_launch,
526 + percentile_cont(0.5) within group (order by s.perigee_km) filter (where s.status='ACTIVE') as median_perigee_km,
527 + percentile_cont(0.5) within group (order by s.inclination_deg) filter (where s.status='ACTIVE') as median_inclination_deg
528 +from constellations k left join satellites s on s.constellation_id = k.id
529 +group by k.id, k.slug, k.name, k.operator_id, k.country_code, k.service_type, k.orbit_class;
530 +create unique index constellation_stats_id_idx on constellation_stats (id);
531 +
532 +create materialized view launch_year_stats as
533 +select l.launch_year as year,
534 + count(*) as launches,
535 + sum(l.payload_count) as payloads,
536 + count(*) filter (where l.launch_site_code is not null) as with_site
537 +from launches l where l.launch_year is not null
538 +group by l.launch_year;
539 +create unique index launch_year_stats_year_idx on launch_year_stats (year);
540 +
541 +create materialized view orbital_bucket_stats as
542 +with b as (
543 + select s.id, s.object_type, s.status, s.perigee_km, s.apogee_km, s.orbit_class,
544 + case
545 + when s.orbit_class = 'GEO' then 'GEO'
546 + when s.orbit_class = 'MEO' then 'MEO'
547 + when s.orbit_class = 'HEO' then 'HEO'
548 + when s.perigee_km is null then 'UNKNOWN'
549 + when s.perigee_km < 200 then '0-200'
550 + when s.perigee_km < 300 then '200-300'
551 + when s.perigee_km < 400 then '300-400'
552 + when s.perigee_km < 500 then '400-500'
553 + when s.perigee_km < 600 then '500-600'
554 + when s.perigee_km < 800 then '600-800'
555 + when s.perigee_km < 1000 then '800-1000'
556 + when s.perigee_km < 2000 then '1000-2000'
557 + else 'OTHER' end as bucket
558 + from satellites s where s.decay_date is null and s.orbit_center = 'EA'
559 +)
560 +select bucket,
561 + count(*) as objects,
562 + count(*) filter (where object_type in ('PAYLOAD','STATION') and status = 'ACTIVE') as active_payloads,
563 + count(*) filter (where object_type in ('PAYLOAD','STATION')) as payloads,
564 + count(*) filter (where object_type = 'DEBRIS') as debris,
565 + count(*) filter (where object_type = 'ROCKET_BODY') as rocket_bodies
566 +from b group by bucket;
567 +create unique index orbital_bucket_stats_bucket_idx on orbital_bucket_stats (bucket);
568 +"""
569 +
570 +
571 +def upgrade() -> None:
572 + for stmt in _split(SQL):
573 + op.execute(stmt)
574 +
575 +
576 +def downgrade() -> None:
577 + op.execute("drop schema public cascade; create schema public;")
578 +
579 +
580 +def _split(sql: str) -> list[str]:
581 + """Split on ';' at end of line — the DDL above never contains ';' inside string literals except in comments."""
582 + out: list[str] = []
583 + buf: list[str] = []
584 + for line in sql.splitlines():
585 + stripped = line.split("--")[0].rstrip() if not line.lstrip().startswith("--") else ""
586 + if line.lstrip().startswith("--") and not buf:
587 + continue
588 + buf.append(line)
589 + if stripped.endswith(";"):
590 + out.append("\n".join(buf))
591 + buf = []
592 + if "".join(buf).strip():
593 + out.append("\n".join(buf))
594 + return out
added package.json +15 −0
@@ -0,0 +1,15 @@
1 +{
2 + "name": "satelliteindex",
3 + "version": "0.1.0",
4 + "private": true,
5 + "description": "SatelliteIndex.io — the world's orbital infrastructure, mapped and indexed. Web workspace (backend is Python, see pyproject.toml).",
6 + "packageManager": "pnpm@11.1.2",
7 + "engines": { "node": ">=22" },
8 + "scripts": {
9 + "dev:web": "pnpm --filter @satelliteindex/web run dev",
10 + "build": "pnpm --filter @satelliteindex/web run build",
11 + "start:web": "pnpm --filter @satelliteindex/web run start",
12 + "typecheck": "pnpm -r run typecheck",
13 + "qa": "pnpm --filter @satelliteindex/web run qa"
14 + }
15 +}
added pnpm-lock.yaml +1796 −0
@@ -0,0 +1,1796 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .: {}
10 +
11 + apps/web:
12 + dependencies:
13 + '@react-three/drei':
14 + specifier: ^10.7.6
15 + version: 10.7.8(@react-three/fiber@9.7.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0))(@types/react@19.3.0)(@types/three@0.182.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0)
16 + '@react-three/fiber':
17 + specifier: ^9.4.0
18 + version: 9.7.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0)
19 + d3-array:
20 + specifier: ^3.2.4
21 + version: 3.2.4
22 + d3-geo:
23 + specifier: ^3.1.1
24 + version: 3.1.1
25 + d3-scale:
26 + specifier: ^4.0.2
27 + version: 4.0.2
28 + d3-shape:
29 + specifier: ^3.2.0
30 + version: 3.2.0
31 + lucide-react:
32 + specifier: ^1.0.0
33 + version: 1.44.0(react@19.2.8)
34 + next:
35 + specifier: 16.3.4
36 + version: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
37 + react:
38 + specifier: 19.2.8
39 + version: 19.2.8
40 + react-dom:
41 + specifier: 19.2.8
42 + version: 19.2.8(react@19.2.8)
43 + server-only:
44 + specifier: ^0.0.1
45 + version: 0.0.1
46 + three:
47 + specifier: ^0.182.0
48 + version: 0.182.0
49 + topojson-client:
50 + specifier: ^3.1.0
51 + version: 3.1.0
52 + world-atlas:
53 + specifier: ^2.0.2
54 + version: 2.0.2
55 + devDependencies:
56 + '@tailwindcss/postcss':
57 + specifier: ^4
58 + version: 4.3.3
59 + '@types/d3-array':
60 + specifier: ^3.2.1
61 + version: 3.2.2
62 + '@types/d3-geo':
63 + specifier: ^3.1.1
64 + version: 3.1.1
65 + '@types/d3-scale':
66 + specifier: ^4.0.9
67 + version: 4.0.9
68 + '@types/d3-shape':
69 + specifier: ^3.1.7
70 + version: 3.2.0
71 + '@types/geojson':
72 + specifier: ^7946.0.16
73 + version: 7946.0.16
74 + '@types/node':
75 + specifier: ^24.0.0
76 + version: 24.13.4
77 + '@types/react':
78 + specifier: ^19
79 + version: 19.3.0
80 + '@types/react-dom':
81 + specifier: ^19
82 + version: 19.3.0(@types/react@19.3.0)
83 + '@types/three':
84 + specifier: ^0.182.0
85 + version: 0.182.0
86 + '@types/topojson-client':
87 + specifier: ^3.1.5
88 + version: 3.1.5
89 + '@types/topojson-specification':
90 + specifier: ^1.0.5
91 + version: 1.0.5
92 + tailwindcss:
93 + specifier: ^4
94 + version: 4.3.3
95 + typescript:
96 + specifier: ^5.9.3
97 + version: 5.9.3
98 +
99 +packages:
100 +
101 + '@alloc/quick-lru@5.3.0':
102 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
103 + engines: {node: '>=10'}
104 +
105 + '@babel/runtime@7.29.7':
106 + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
107 + engines: {node: '>=6.9.0'}
108 +
109 + '@dimforge/rapier3d-compat@0.12.0':
110 + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
111 +
112 + '@emnapi/runtime@1.11.3':
113 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
114 +
115 + '@img/colour@1.1.0':
116 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
117 + engines: {node: '>=18'}
118 +
119 + '@img/sharp-darwin-arm64@0.35.4':
120 + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
121 + engines: {node: '>=20.9.0'}
122 + cpu: [arm64]
123 + os: [darwin]
124 +
125 + '@img/sharp-darwin-x64@0.35.4':
126 + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
127 + engines: {node: '>=20.9.0'}
128 + cpu: [x64]
129 + os: [darwin]
130 +
131 + '@img/sharp-freebsd-wasm32@0.35.4':
132 + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
133 + engines: {node: '>=20.9.0'}
134 + os: [freebsd]
135 +
136 + '@img/sharp-libvips-darwin-arm64@1.3.3':
137 + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
138 + cpu: [arm64]
139 + os: [darwin]
140 +
141 + '@img/sharp-libvips-darwin-x64@1.3.3':
142 + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
143 + cpu: [x64]
144 + os: [darwin]
145 +
146 + '@img/sharp-libvips-linux-arm64@1.3.3':
147 + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
148 + cpu: [arm64]
149 + os: [linux]
150 + libc: [glibc]
151 +
152 + '@img/sharp-libvips-linux-arm@1.3.3':
153 + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
154 + cpu: [arm]
155 + os: [linux]
156 + libc: [glibc]
157 +
158 + '@img/sharp-libvips-linux-ppc64@1.3.3':
159 + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
160 + cpu: [ppc64]
161 + os: [linux]
162 + libc: [glibc]
163 +
164 + '@img/sharp-libvips-linux-riscv64@1.3.3':
165 + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
166 + cpu: [riscv64]
167 + os: [linux]
168 + libc: [glibc]
169 +
170 + '@img/sharp-libvips-linux-s390x@1.3.3':
171 + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
172 + cpu: [s390x]
173 + os: [linux]
174 + libc: [glibc]
175 +
176 + '@img/sharp-libvips-linux-x64@1.3.3':
177 + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
178 + cpu: [x64]
179 + os: [linux]
180 + libc: [glibc]
181 +
182 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
183 + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
184 + cpu: [arm64]
185 + os: [linux]
186 + libc: [musl]
187 +
188 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
189 + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
190 + cpu: [x64]
191 + os: [linux]
192 + libc: [musl]
193 +
194 + '@img/sharp-linux-arm64@0.35.4':
195 + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
196 + engines: {node: '>=20.9.0'}
197 + cpu: [arm64]
198 + os: [linux]
199 + libc: [glibc]
200 +
201 + '@img/sharp-linux-arm@0.35.4':
202 + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
203 + engines: {node: '>=20.9.0'}
204 + cpu: [arm]
205 + os: [linux]
206 + libc: [glibc]
207 +
208 + '@img/sharp-linux-ppc64@0.35.4':
209 + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
210 + engines: {node: '>=20.9.0'}
211 + cpu: [ppc64]
212 + os: [linux]
213 + libc: [glibc]
214 +
215 + '@img/sharp-linux-riscv64@0.35.4':
216 + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
217 + engines: {node: '>=20.9.0'}
218 + cpu: [riscv64]
219 + os: [linux]
220 + libc: [glibc]
221 +
222 + '@img/sharp-linux-s390x@0.35.4':
223 + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
224 + engines: {node: '>=20.9.0'}
225 + cpu: [s390x]
226 + os: [linux]
227 + libc: [glibc]
228 +
229 + '@img/sharp-linux-x64@0.35.4':
230 + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
231 + engines: {node: '>=20.9.0'}
232 + cpu: [x64]
233 + os: [linux]
234 + libc: [glibc]
235 +
236 + '@img/sharp-linuxmusl-arm64@0.35.4':
237 + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
238 + engines: {node: '>=20.9.0'}
239 + cpu: [arm64]
240 + os: [linux]
241 + libc: [musl]
242 +
243 + '@img/sharp-linuxmusl-x64@0.35.4':
244 + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
245 + engines: {node: '>=20.9.0'}
246 + cpu: [x64]
247 + os: [linux]
248 + libc: [musl]
249 +
250 + '@img/sharp-wasm32@0.35.4':
251 + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
252 + engines: {node: '>=20.9.0'}
253 +
254 + '@img/sharp-webcontainers-wasm32@0.35.4':
255 + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
256 + engines: {node: '>=20.9.0'}
257 + cpu: [wasm32]
258 +
259 + '@img/sharp-win32-arm64@0.35.4':
260 + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
261 + engines: {node: '>=20.9.0'}
262 + cpu: [arm64]
263 + os: [win32]
264 +
265 + '@img/sharp-win32-ia32@0.35.4':
266 + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
267 + engines: {node: ^20.9.0}
268 + cpu: [ia32]
269 + os: [win32]
270 +
271 + '@img/sharp-win32-x64@0.35.4':
272 + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
273 + engines: {node: '>=20.9.0'}
274 + cpu: [x64]
275 + os: [win32]
276 +
277 + '@jridgewell/gen-mapping@0.3.13':
278 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
279 +
280 + '@jridgewell/remapping@2.3.5':
281 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
282 +
283 + '@jridgewell/resolve-uri@3.1.2':
284 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
285 + engines: {node: '>=6.0.0'}
286 +
287 + '@jridgewell/sourcemap-codec@1.6.0':
288 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
289 +
290 + '@jridgewell/trace-mapping@0.3.31':
291 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
292 +
293 + '@mediapipe/tasks-vision@0.10.17':
294 + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==}
295 +
296 + '@monogrid/gainmap-js@3.4.0':
297 + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==}
298 + peerDependencies:
299 + three: '>= 0.159.0'
300 +
301 + '@next/env@16.3.4':
302 + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
303 +
304 + '@next/swc-darwin-arm64@16.3.4':
305 + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
306 + engines: {node: '>= 10'}
307 + cpu: [arm64]
308 + os: [darwin]
309 +
310 + '@next/swc-darwin-x64@16.3.4':
311 + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
312 + engines: {node: '>= 10'}
313 + cpu: [x64]
314 + os: [darwin]
315 +
316 + '@next/swc-linux-arm64-gnu@16.3.4':
317 + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
318 + engines: {node: '>= 10'}
319 + cpu: [arm64]
320 + os: [linux]
321 + libc: [glibc]
322 +
323 + '@next/swc-linux-arm64-musl@16.3.4':
324 + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
325 + engines: {node: '>= 10'}
326 + cpu: [arm64]
327 + os: [linux]
328 + libc: [musl]
329 +
330 + '@next/swc-linux-x64-gnu@16.3.4':
331 + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
332 + engines: {node: '>= 10'}
333 + cpu: [x64]
334 + os: [linux]
335 + libc: [glibc]
336 +
337 + '@next/swc-linux-x64-musl@16.3.4':
338 + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
339 + engines: {node: '>= 10'}
340 + cpu: [x64]
341 + os: [linux]
342 + libc: [musl]
343 +
344 + '@next/swc-win32-arm64-msvc@16.3.4':
345 + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
346 + engines: {node: '>= 10'}
347 + cpu: [arm64]
348 + os: [win32]
349 +
350 + '@next/swc-win32-x64-msvc@16.3.4':
351 + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
352 + engines: {node: '>= 10'}
353 + cpu: [x64]
354 + os: [win32]
355 +
356 + '@react-three/drei@10.7.8':
357 + resolution: {integrity: sha512-rJXyuzLm2Xq0kafHuR47ajDGbOe/pEhzIr4m8E8zwzQs0iNjloFDqBwRhrXmP/w+onLeYyN3EYPFW/cwWK/4yA==}
358 + peerDependencies:
359 + '@react-three/fiber': ^9.0.0
360 + react: ^19
361 + react-dom: ^19
362 + three: '>=0.159'
363 + peerDependenciesMeta:
364 + react-dom:
365 + optional: true
366 +
367 + '@react-three/fiber@9.7.0':
368 + resolution: {integrity: sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==}
369 + peerDependencies:
370 + expo: '>=43.0'
371 + expo-asset: '>=8.4'
372 + expo-file-system: '>=11.0'
373 + expo-gl: '>=11.0'
374 + react: '>=19 <19.3'
375 + react-dom: '>=19 <19.3'
376 + react-native: '>=0.78'
377 + three: '>=0.156'
378 + peerDependenciesMeta:
379 + expo:
380 + optional: true
381 + expo-asset:
382 + optional: true
383 + expo-file-system:
384 + optional: true
385 + expo-gl:
386 + optional: true
387 + react-dom:
388 + optional: true
389 + react-native:
390 + optional: true
391 +
392 + '@swc/helpers@0.5.23':
393 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
394 +
395 + '@tailwindcss/node@4.3.3':
396 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
397 +
398 + '@tailwindcss/oxide-android-arm64@4.3.3':
399 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
400 + engines: {node: '>= 20'}
401 + cpu: [arm64]
402 + os: [android]
403 +
404 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
405 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
406 + engines: {node: '>= 20'}
407 + cpu: [arm64]
408 + os: [darwin]
409 +
410 + '@tailwindcss/oxide-darwin-x64@4.3.3':
411 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
412 + engines: {node: '>= 20'}
413 + cpu: [x64]
414 + os: [darwin]
415 +
416 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
417 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
418 + engines: {node: '>= 20'}
419 + cpu: [x64]
420 + os: [freebsd]
421 +
422 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
423 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
424 + engines: {node: '>= 20'}
425 + cpu: [arm]
426 + os: [linux]
427 +
428 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
429 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
430 + engines: {node: '>= 20'}
431 + cpu: [arm64]
432 + os: [linux]
433 + libc: [glibc]
434 +
435 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
436 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
437 + engines: {node: '>= 20'}
438 + cpu: [arm64]
439 + os: [linux]
440 + libc: [musl]
441 +
442 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
443 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
444 + engines: {node: '>= 20'}
445 + cpu: [x64]
446 + os: [linux]
447 + libc: [glibc]
448 +
449 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
450 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
451 + engines: {node: '>= 20'}
452 + cpu: [x64]
453 + os: [linux]
454 + libc: [musl]
455 +
456 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
457 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
458 + engines: {node: '>=14.0.0'}
459 + cpu: [wasm32]
460 + bundledDependencies:
461 + - '@napi-rs/wasm-runtime'
462 + - '@emnapi/core'
463 + - '@emnapi/runtime'
464 + - '@tybys/wasm-util'
465 + - '@emnapi/wasi-threads'
466 + - tslib
467 +
468 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
469 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
470 + engines: {node: '>= 20'}
471 + cpu: [arm64]
472 + os: [win32]
473 +
474 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
475 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
476 + engines: {node: '>= 20'}
477 + cpu: [x64]
478 + os: [win32]
479 +
480 + '@tailwindcss/oxide@4.3.3':
481 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
482 + engines: {node: '>= 20'}
483 +
484 + '@tailwindcss/postcss@4.3.3':
485 + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
486 +
487 + '@tweenjs/tween.js@23.1.3':
488 + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
489 +
490 + '@types/d3-array@3.2.2':
491 + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
492 +
493 + '@types/d3-geo@3.1.1':
494 + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
495 +
496 + '@types/d3-path@3.1.1':
497 + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
498 +
499 + '@types/d3-scale@4.0.9':
500 + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
501 +
502 + '@types/d3-shape@3.2.0':
503 + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
504 +
505 + '@types/d3-time@3.0.4':
506 + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
507 +
508 + '@types/draco3d@1.4.10':
509 + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
510 +
511 + '@types/geojson@7946.0.16':
512 + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
513 +
514 + '@types/node@24.13.4':
515 + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
516 +
517 + '@types/offscreencanvas@2019.7.3':
518 + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
519 +
520 + '@types/react-dom@19.3.0':
521 + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
522 + peerDependencies:
523 + '@types/react': ^19.3.0
524 +
525 + '@types/react-reconciler@0.28.9':
526 + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
527 + peerDependencies:
528 + '@types/react': '*'
529 +
530 + '@types/react@19.3.0':
531 + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
532 +
533 + '@types/stats.js@0.17.4':
534 + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
535 +
536 + '@types/three@0.182.0':
537 + resolution: {integrity: sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==}
538 +
539 + '@types/topojson-client@3.1.5':
540 + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
541 +
542 + '@types/topojson-specification@1.0.5':
543 + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
544 +
545 + '@types/webxr@0.5.24':
546 + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
547 +
548 + '@use-gesture/core@10.3.1':
549 + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
550 +
551 + '@use-gesture/react@10.3.1':
552 + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
553 + peerDependencies:
554 + react: '>= 16.8.0'
555 +
556 + '@webgpu/types@0.1.72':
557 + resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==}
558 +
559 + base64-js@1.5.1:
560 + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
561 +
562 + baseline-browser-mapping@2.11.22:
563 + resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==}
564 + engines: {node: '>=6.0.0'}
565 + hasBin: true
566 +
567 + bidi-js@1.1.0:
568 + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
569 +
570 + buffer@6.0.3:
571 + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
572 +
573 + camera-controls@3.1.2:
574 + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==}
575 + engines: {node: '>=22.0.0', npm: '>=10.5.1'}
576 + peerDependencies:
577 + three: '>=0.126.1'
578 +
579 + caniuse-lite@1.0.30001810:
580 + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
581 +
582 + client-only@0.0.1:
583 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
584 +
585 + commander@2.20.3:
586 + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
587 +
588 + cross-env@7.0.3:
589 + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
590 + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
591 + hasBin: true
592 +
593 + cross-spawn@7.0.6:
594 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
595 + engines: {node: '>= 8'}
596 +
597 + csstype@3.2.3:
598 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
599 +
600 + d3-array@3.2.4:
601 + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
602 + engines: {node: '>=12'}
603 +
604 + d3-color@3.1.0:
605 + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
606 + engines: {node: '>=12'}
607 +
608 + d3-format@3.1.2:
609 + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
610 + engines: {node: '>=12'}
611 +
612 + d3-geo@3.1.1:
613 + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
614 + engines: {node: '>=12'}
615 +
616 + d3-interpolate@3.0.1:
617 + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
618 + engines: {node: '>=12'}
619 +
620 + d3-path@3.1.0:
621 + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
622 + engines: {node: '>=12'}
623 +
624 + d3-scale@4.0.2:
625 + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
626 + engines: {node: '>=12'}
627 +
628 + d3-shape@3.2.0:
629 + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
630 + engines: {node: '>=12'}
631 +
632 + d3-time-format@4.1.0:
633 + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
634 + engines: {node: '>=12'}
635 +
636 + d3-time@3.1.0:
637 + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
638 + engines: {node: '>=12'}
639 +
640 + detect-gpu@5.0.70:
641 + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
642 +
643 + detect-libc@2.1.2:
644 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
645 + engines: {node: '>=8'}
646 +
647 + draco3d@1.5.7:
648 + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
649 +
650 + enhanced-resolve@5.24.5:
651 + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
652 + engines: {node: '>=10.13.0'}
653 +
654 + fflate@0.6.11:
655 + resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==}
656 +
657 + fflate@0.8.3:
658 + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
659 +
660 + glsl-noise@0.0.0:
661 + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==}
662 +
663 + graceful-fs@4.2.11:
664 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
665 +
666 + hls.js@1.7.2:
667 + resolution: {integrity: sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==}
668 +
669 + ieee754@1.2.1:
670 + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
671 +
672 + immediate@3.0.6:
673 + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
674 +
675 + internmap@2.0.3:
676 + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
677 + engines: {node: '>=12'}
678 +
679 + is-promise@2.2.2:
680 + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
681 +
682 + isexe@2.0.0:
683 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
684 +
685 + its-fine@2.0.0:
686 + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
687 + peerDependencies:
688 + react: ^19.0.0
689 +
690 + jiti@2.7.0:
691 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
692 + hasBin: true
693 +
694 + lie@3.3.0:
695 + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
696 +
697 + lightningcss-android-arm64@1.32.0:
698 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
699 + engines: {node: '>= 12.0.0'}
700 + cpu: [arm64]
701 + os: [android]
702 +
703 + lightningcss-darwin-arm64@1.32.0:
704 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
705 + engines: {node: '>= 12.0.0'}
706 + cpu: [arm64]
707 + os: [darwin]
708 +
709 + lightningcss-darwin-x64@1.32.0:
710 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
711 + engines: {node: '>= 12.0.0'}
712 + cpu: [x64]
713 + os: [darwin]
714 +
715 + lightningcss-freebsd-x64@1.32.0:
716 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
717 + engines: {node: '>= 12.0.0'}
718 + cpu: [x64]
719 + os: [freebsd]
720 +
721 + lightningcss-linux-arm-gnueabihf@1.32.0:
722 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
723 + engines: {node: '>= 12.0.0'}
724 + cpu: [arm]
725 + os: [linux]
726 +
727 + lightningcss-linux-arm64-gnu@1.32.0:
728 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
729 + engines: {node: '>= 12.0.0'}
730 + cpu: [arm64]
731 + os: [linux]
732 + libc: [glibc]
733 +
734 + lightningcss-linux-arm64-musl@1.32.0:
735 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
736 + engines: {node: '>= 12.0.0'}
737 + cpu: [arm64]
738 + os: [linux]
739 + libc: [musl]
740 +
741 + lightningcss-linux-x64-gnu@1.32.0:
742 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
743 + engines: {node: '>= 12.0.0'}
744 + cpu: [x64]
745 + os: [linux]
746 + libc: [glibc]
747 +
748 + lightningcss-linux-x64-musl@1.32.0:
749 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
750 + engines: {node: '>= 12.0.0'}
751 + cpu: [x64]
752 + os: [linux]
753 + libc: [musl]
754 +
755 + lightningcss-win32-arm64-msvc@1.32.0:
756 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
757 + engines: {node: '>= 12.0.0'}
758 + cpu: [arm64]
759 + os: [win32]
760 +
761 + lightningcss-win32-x64-msvc@1.32.0:
762 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
763 + engines: {node: '>= 12.0.0'}
764 + cpu: [x64]
765 + os: [win32]
766 +
767 + lightningcss@1.32.0:
768 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
769 + engines: {node: '>= 12.0.0'}
770 +
771 + lucide-react@1.44.0:
772 + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==}
773 + peerDependencies:
774 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
775 +
776 + maath@0.10.8:
777 + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==}
778 + peerDependencies:
779 + '@types/three': '>=0.134.0'
780 + three: '>=0.134.0'
781 +
782 + magic-string@0.30.21:
783 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
784 +
785 + meshline@3.3.1:
786 + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
787 + peerDependencies:
788 + three: '>=0.137'
789 +
790 + meshoptimizer@0.22.0:
791 + resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
792 +
793 + nanoid@3.3.19:
794 + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
795 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
796 + hasBin: true
797 +
798 + next@16.3.4:
799 + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
800 + engines: {node: '>=20.9.0'}
801 + hasBin: true
802 + peerDependencies:
803 + '@opentelemetry/api': ^1.1.0
804 + '@playwright/test': ^1.51.1
805 + babel-plugin-react-compiler: '*'
806 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
807 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
808 + sass: ^1.3.0
809 + peerDependenciesMeta:
810 + '@opentelemetry/api':
811 + optional: true
812 + '@playwright/test':
813 + optional: true
814 + babel-plugin-react-compiler:
815 + optional: true
816 + sass:
817 + optional: true
818 +
819 + path-key@3.1.1:
820 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
821 + engines: {node: '>=8'}
822 +
823 + picocolors@1.1.1:
824 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
825 +
826 + postcss@8.5.23:
827 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
828 + engines: {node: ^10 || ^12 || >=14}
829 +
830 + postcss@8.5.28:
831 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
832 + engines: {node: ^10 || ^12 || >=14}
833 +
834 + potpack@1.0.2:
835 + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
836 +
837 + promise-worker-transferable@1.0.4:
838 + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
839 +
840 + react-dom@19.2.8:
841 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
842 + peerDependencies:
843 + react: ^19.2.8
844 +
845 + react-use-measure@2.1.7:
846 + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
847 + peerDependencies:
848 + react: '>=16.13'
849 + react-dom: '>=16.13'
850 + peerDependenciesMeta:
851 + react-dom:
852 + optional: true
853 +
854 + react@19.2.8:
855 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
856 + engines: {node: '>=0.10.0'}
857 +
858 + require-from-string@2.0.2:
859 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
860 + engines: {node: '>=0.10.0'}
861 +
862 + scheduler@0.27.0:
863 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
864 +
865 + semver@7.8.5:
866 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
867 + engines: {node: '>=10'}
868 + hasBin: true
869 +
870 + server-only@0.0.1:
871 + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
872 +
873 + sharp@0.35.4:
874 + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
875 + engines: {node: '>=20.9.0'}
876 + peerDependencies:
877 + '@types/node': '*'
878 + peerDependenciesMeta:
879 + '@types/node':
880 + optional: true
881 +
882 + shebang-command@2.0.0:
883 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
884 + engines: {node: '>=8'}
885 +
886 + shebang-regex@3.0.0:
887 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
888 + engines: {node: '>=8'}
889 +
890 + source-map-js@1.2.1:
891 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
892 + engines: {node: '>=0.10.0'}
893 +
894 + stats-gl@2.4.2:
895 + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
896 + peerDependencies:
897 + '@types/three': '*'
898 + three: '*'
899 +
900 + stats.js@0.17.0:
901 + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==}
902 +
903 + styled-jsx@5.1.6:
904 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
905 + engines: {node: '>= 12.0.0'}
906 + peerDependencies:
907 + '@babel/core': '*'
908 + babel-plugin-macros: '*'
909 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
910 + peerDependenciesMeta:
911 + '@babel/core':
912 + optional: true
913 + babel-plugin-macros:
914 + optional: true
915 +
916 + suspend-react@0.1.3:
917 + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
918 + peerDependencies:
919 + react: '>=17.0'
920 +
921 + tailwindcss@4.3.3:
922 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
923 +
924 + tapable@2.3.3:
925 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
926 + engines: {node: '>=6'}
927 +
928 + three-mesh-bvh@0.8.3:
929 + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==}
930 + peerDependencies:
931 + three: '>= 0.159.0'
932 +
933 + three-stdlib@2.36.1:
934 + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==}
935 + peerDependencies:
936 + three: '>=0.128.0'
937 +
938 + three@0.182.0:
939 + resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==}
940 +
941 + topojson-client@3.1.0:
942 + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
943 + hasBin: true
944 +
945 + troika-three-text@0.52.5:
946 + resolution: {integrity: sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==}
947 + peerDependencies:
948 + three: '>=0.125.0'
949 +
950 + troika-three-utils@0.52.5:
951 + resolution: {integrity: sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==}
952 + peerDependencies:
953 + three: '>=0.125.0'
954 +
955 + troika-worker-utils@0.52.0:
956 + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==}
957 +
958 + tslib@2.8.1:
959 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
960 +
961 + tunnel-rat@0.1.2:
962 + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
963 +
964 + typescript@5.9.3:
965 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
966 + engines: {node: '>=14.17'}
967 + hasBin: true
968 +
969 + undici-types@7.18.2:
970 + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
971 +
972 + use-sync-external-store@1.7.0:
973 + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
974 + peerDependencies:
975 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
976 +
977 + utility-types@3.11.0:
978 + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==}
979 + engines: {node: '>= 4'}
980 +
981 + webgl-constants@1.1.1:
982 + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==}
983 +
984 + webgl-sdf-generator@1.1.1:
985 + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==}
986 +
987 + which@2.0.2:
988 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
989 + engines: {node: '>= 8'}
990 + hasBin: true
991 +
992 + world-atlas@2.0.2:
993 + resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
994 +
995 + zustand@4.5.7:
996 + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
997 + engines: {node: '>=12.7.0'}
998 + peerDependencies:
999 + '@types/react': '>=16.8'
1000 + immer: '>=9.0.6'
1001 + react: '>=16.8'
1002 + peerDependenciesMeta:
1003 + '@types/react':
1004 + optional: true
1005 + immer:
1006 + optional: true
1007 + react:
1008 + optional: true
1009 +
1010 + zustand@5.0.15:
1011 + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==}
1012 + engines: {node: '>=12.20.0'}
1013 + peerDependencies:
1014 + '@types/react': '>=18.0.0'
1015 + immer: '>=9.0.6'
1016 + react: '>=18.0.0'
1017 + use-sync-external-store: '>=1.2.0'
1018 + peerDependenciesMeta:
1019 + '@types/react':
1020 + optional: true
1021 + immer:
1022 + optional: true
1023 + react:
1024 + optional: true
1025 + use-sync-external-store:
1026 + optional: true
1027 +
1028 +snapshots:
1029 +
1030 + '@alloc/quick-lru@5.3.0': {}
1031 +
1032 + '@babel/runtime@7.29.7': {}
1033 +
1034 + '@dimforge/rapier3d-compat@0.12.0': {}
1035 +
1036 + '@emnapi/runtime@1.11.3':
1037 + dependencies:
1038 + tslib: 2.8.1
1039 + optional: true
1040 +
1041 + '@img/colour@1.1.0':
1042 + optional: true
1043 +
1044 + '@img/sharp-darwin-arm64@0.35.4':
1045 + optionalDependencies:
1046 + '@img/sharp-libvips-darwin-arm64': 1.3.3
1047 + optional: true
1048 +
1049 + '@img/sharp-darwin-x64@0.35.4':
1050 + optionalDependencies:
1051 + '@img/sharp-libvips-darwin-x64': 1.3.3
1052 + optional: true
1053 +
1054 + '@img/sharp-freebsd-wasm32@0.35.4':
1055 + dependencies:
1056 + '@img/sharp-wasm32': 0.35.4
1057 + optional: true
1058 +
1059 + '@img/sharp-libvips-darwin-arm64@1.3.3':
1060 + optional: true
1061 +
1062 + '@img/sharp-libvips-darwin-x64@1.3.3':
1063 + optional: true
1064 +
1065 + '@img/sharp-libvips-linux-arm64@1.3.3':
1066 + optional: true
1067 +
1068 + '@img/sharp-libvips-linux-arm@1.3.3':
1069 + optional: true
1070 +
1071 + '@img/sharp-libvips-linux-ppc64@1.3.3':
1072 + optional: true
1073 +
1074 + '@img/sharp-libvips-linux-riscv64@1.3.3':
1075 + optional: true
1076 +
1077 + '@img/sharp-libvips-linux-s390x@1.3.3':
1078 + optional: true
1079 +
1080 + '@img/sharp-libvips-linux-x64@1.3.3':
1081 + optional: true
1082 +
1083 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
1084 + optional: true
1085 +
1086 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
1087 + optional: true
1088 +
1089 + '@img/sharp-linux-arm64@0.35.4':
1090 + optionalDependencies:
1091 + '@img/sharp-libvips-linux-arm64': 1.3.3
1092 + optional: true
1093 +
1094 + '@img/sharp-linux-arm@0.35.4':
1095 + optionalDependencies:
1096 + '@img/sharp-libvips-linux-arm': 1.3.3
1097 + optional: true
1098 +
1099 + '@img/sharp-linux-ppc64@0.35.4':
1100 + optionalDependencies:
1101 + '@img/sharp-libvips-linux-ppc64': 1.3.3
1102 + optional: true
1103 +
1104 + '@img/sharp-linux-riscv64@0.35.4':
1105 + optionalDependencies:
1106 + '@img/sharp-libvips-linux-riscv64': 1.3.3
1107 + optional: true
1108 +
1109 + '@img/sharp-linux-s390x@0.35.4':
1110 + optionalDependencies:
1111 + '@img/sharp-libvips-linux-s390x': 1.3.3
1112 + optional: true
1113 +
1114 + '@img/sharp-linux-x64@0.35.4':
1115 + optionalDependencies:
1116 + '@img/sharp-libvips-linux-x64': 1.3.3
1117 + optional: true
1118 +
1119 + '@img/sharp-linuxmusl-arm64@0.35.4':
1120 + optionalDependencies:
1121 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
1122 + optional: true
1123 +
1124 + '@img/sharp-linuxmusl-x64@0.35.4':
1125 + optionalDependencies:
1126 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
1127 + optional: true
1128 +
1129 + '@img/sharp-wasm32@0.35.4':
1130 + dependencies:
1131 + '@emnapi/runtime': 1.11.3
1132 + optional: true
1133 +
1134 + '@img/sharp-webcontainers-wasm32@0.35.4':
1135 + dependencies:
1136 + '@img/sharp-wasm32': 0.35.4
1137 + optional: true
1138 +
1139 + '@img/sharp-win32-arm64@0.35.4':
1140 + optional: true
1141 +
1142 + '@img/sharp-win32-ia32@0.35.4':
1143 + optional: true
1144 +
1145 + '@img/sharp-win32-x64@0.35.4':
1146 + optional: true
1147 +
1148 + '@jridgewell/gen-mapping@0.3.13':
1149 + dependencies:
1150 + '@jridgewell/sourcemap-codec': 1.6.0
1151 + '@jridgewell/trace-mapping': 0.3.31
1152 +
1153 + '@jridgewell/remapping@2.3.5':
1154 + dependencies:
1155 + '@jridgewell/gen-mapping': 0.3.13
1156 + '@jridgewell/trace-mapping': 0.3.31
1157 +
1158 + '@jridgewell/resolve-uri@3.1.2': {}
1159 +
1160 + '@jridgewell/sourcemap-codec@1.6.0': {}
1161 +
1162 + '@jridgewell/trace-mapping@0.3.31':
1163 + dependencies:
1164 + '@jridgewell/resolve-uri': 3.1.2
1165 + '@jridgewell/sourcemap-codec': 1.6.0
1166 +
1167 + '@mediapipe/tasks-vision@0.10.17': {}
1168 +
1169 + '@monogrid/gainmap-js@3.4.0(three@0.182.0)':
1170 + dependencies:
1171 + promise-worker-transferable: 1.0.4
1172 + three: 0.182.0
1173 +
1174 + '@next/env@16.3.4': {}
1175 +
1176 + '@next/swc-darwin-arm64@16.3.4':
1177 + optional: true
1178 +
1179 + '@next/swc-darwin-x64@16.3.4':
1180 + optional: true
1181 +
1182 + '@next/swc-linux-arm64-gnu@16.3.4':
1183 + optional: true
1184 +
1185 + '@next/swc-linux-arm64-musl@16.3.4':
1186 + optional: true
1187 +
1188 + '@next/swc-linux-x64-gnu@16.3.4':
1189 + optional: true
1190 +
1191 + '@next/swc-linux-x64-musl@16.3.4':
1192 + optional: true
1193 +
1194 + '@next/swc-win32-arm64-msvc@16.3.4':
1195 + optional: true
1196 +
1197 + '@next/swc-win32-x64-msvc@16.3.4':
1198 + optional: true
1199 +
1200 + '@react-three/drei@10.7.8(@react-three/fiber@9.7.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0))(@types/react@19.3.0)(@types/three@0.182.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0)':
1201 + dependencies:
1202 + '@babel/runtime': 7.29.7
1203 + '@mediapipe/tasks-vision': 0.10.17
1204 + '@monogrid/gainmap-js': 3.4.0(three@0.182.0)
1205 + '@react-three/fiber': 9.7.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0)
1206 + '@use-gesture/react': 10.3.1(react@19.2.8)
1207 + camera-controls: 3.1.2(three@0.182.0)
1208 + cross-env: 7.0.3
1209 + detect-gpu: 5.0.70
1210 + glsl-noise: 0.0.0
1211 + hls.js: 1.7.2
1212 + maath: 0.10.8(@types/three@0.182.0)(three@0.182.0)
1213 + meshline: 3.3.1(three@0.182.0)
1214 + react: 19.2.8
1215 + stats-gl: 2.4.2(@types/three@0.182.0)(three@0.182.0)
1216 + stats.js: 0.17.0
1217 + suspend-react: 0.1.3(react@19.2.8)
1218 + three: 0.182.0
1219 + three-mesh-bvh: 0.8.3(three@0.182.0)
1220 + three-stdlib: 2.36.1(three@0.182.0)
1221 + troika-three-text: 0.52.5(three@0.182.0)
1222 + tunnel-rat: 0.1.2(@types/react@19.3.0)(react@19.2.8)
1223 + use-sync-external-store: 1.7.0(react@19.2.8)
1224 + utility-types: 3.11.0
1225 + zustand: 5.0.15(@types/react@19.3.0)(react@19.2.8)(use-sync-external-store@1.7.0(react@19.2.8))
1226 + optionalDependencies:
1227 + react-dom: 19.2.8(react@19.2.8)
1228 + transitivePeerDependencies:
1229 + - '@types/react'
1230 + - '@types/three'
1231 + - immer
1232 +
1233 + '@react-three/fiber@9.7.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.182.0)':
1234 + dependencies:
1235 + '@babel/runtime': 7.29.7
1236 + '@types/webxr': 0.5.24
1237 + base64-js: 1.5.1
1238 + buffer: 6.0.3
1239 + its-fine: 2.0.0(@types/react@19.3.0)(react@19.2.8)
1240 + react: 19.2.8
1241 + react-use-measure: 2.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
1242 + scheduler: 0.27.0
1243 + suspend-react: 0.1.3(react@19.2.8)
1244 + three: 0.182.0
1245 + use-sync-external-store: 1.7.0(react@19.2.8)
1246 + zustand: 5.0.15(@types/react@19.3.0)(react@19.2.8)(use-sync-external-store@1.7.0(react@19.2.8))
1247 + optionalDependencies:
1248 + react-dom: 19.2.8(react@19.2.8)
1249 + transitivePeerDependencies:
1250 + - '@types/react'
1251 + - immer
1252 +
1253 + '@swc/helpers@0.5.23':
1254 + dependencies:
1255 + tslib: 2.8.1
1256 +
1257 + '@tailwindcss/node@4.3.3':
1258 + dependencies:
1259 + '@jridgewell/remapping': 2.3.5
1260 + enhanced-resolve: 5.24.5
1261 + jiti: 2.7.0
1262 + lightningcss: 1.32.0
1263 + magic-string: 0.30.21
1264 + source-map-js: 1.2.1
1265 + tailwindcss: 4.3.3
1266 +
1267 + '@tailwindcss/oxide-android-arm64@4.3.3':
1268 + optional: true
1269 +
1270 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
1271 + optional: true
1272 +
1273 + '@tailwindcss/oxide-darwin-x64@4.3.3':
1274 + optional: true
1275 +
1276 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
1277 + optional: true
1278 +
1279 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
1280 + optional: true
1281 +
1282 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
1283 + optional: true
1284 +
1285 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
1286 + optional: true
1287 +
1288 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
1289 + optional: true
1290 +
1291 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
1292 + optional: true
1293 +
1294 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
1295 + optional: true
1296 +
1297 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
1298 + optional: true
1299 +
1300 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
1301 + optional: true
1302 +
1303 + '@tailwindcss/oxide@4.3.3':
1304 + optionalDependencies:
1305 + '@tailwindcss/oxide-android-arm64': 4.3.3
1306 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
1307 + '@tailwindcss/oxide-darwin-x64': 4.3.3
1308 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
1309 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
1310 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
1311 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
1312 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
1313 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
1314 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
1315 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
1316 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
1317 +
1318 + '@tailwindcss/postcss@4.3.3':
1319 + dependencies:
1320 + '@alloc/quick-lru': 5.3.0
1321 + '@tailwindcss/node': 4.3.3
1322 + '@tailwindcss/oxide': 4.3.3
1323 + postcss: 8.5.28
1324 + tailwindcss: 4.3.3
1325 +
1326 + '@tweenjs/tween.js@23.1.3': {}
1327 +
1328 + '@types/d3-array@3.2.2': {}
1329 +
1330 + '@types/d3-geo@3.1.1':
1331 + dependencies:
1332 + '@types/geojson': 7946.0.16
1333 +
1334 + '@types/d3-path@3.1.1': {}
1335 +
1336 + '@types/d3-scale@4.0.9':
1337 + dependencies:
1338 + '@types/d3-time': 3.0.4
1339 +
1340 + '@types/d3-shape@3.2.0':
1341 + dependencies:
1342 + '@types/d3-path': 3.1.1
1343 +
1344 + '@types/d3-time@3.0.4': {}
1345 +
1346 + '@types/draco3d@1.4.10': {}
1347 +
1348 + '@types/geojson@7946.0.16': {}
1349 +
1350 + '@types/node@24.13.4':
1351 + dependencies:
1352 + undici-types: 7.18.2
1353 +
1354 + '@types/offscreencanvas@2019.7.3': {}
1355 +
1356 + '@types/react-dom@19.3.0(@types/react@19.3.0)':
1357 + dependencies:
1358 + '@types/react': 19.3.0
1359 +
1360 + '@types/react-reconciler@0.28.9(@types/react@19.3.0)':
1361 + dependencies:
1362 + '@types/react': 19.3.0
1363 +
1364 + '@types/react@19.3.0':
1365 + dependencies:
1366 + csstype: 3.2.3
1367 +
1368 + '@types/stats.js@0.17.4': {}
1369 +
1370 + '@types/three@0.182.0':
1371 + dependencies:
1372 + '@dimforge/rapier3d-compat': 0.12.0
1373 + '@tweenjs/tween.js': 23.1.3
1374 + '@types/stats.js': 0.17.4
1375 + '@types/webxr': 0.5.24
1376 + '@webgpu/types': 0.1.72
1377 + fflate: 0.8.3
1378 + meshoptimizer: 0.22.0
1379 +
1380 + '@types/topojson-client@3.1.5':
1381 + dependencies:
1382 + '@types/geojson': 7946.0.16
1383 + '@types/topojson-specification': 1.0.5
1384 +
1385 + '@types/topojson-specification@1.0.5':
1386 + dependencies:
1387 + '@types/geojson': 7946.0.16
1388 +
1389 + '@types/webxr@0.5.24': {}
1390 +
1391 + '@use-gesture/core@10.3.1': {}
1392 +
1393 + '@use-gesture/react@10.3.1(react@19.2.8)':
1394 + dependencies:
1395 + '@use-gesture/core': 10.3.1
1396 + react: 19.2.8
1397 +
1398 + '@webgpu/types@0.1.72': {}
1399 +
1400 + base64-js@1.5.1: {}
1401 +
1402 + baseline-browser-mapping@2.11.22: {}
1403 +
1404 + bidi-js@1.1.0:
1405 + dependencies:
1406 + require-from-string: 2.0.2
1407 +
1408 + buffer@6.0.3:
1409 + dependencies:
1410 + base64-js: 1.5.1
1411 + ieee754: 1.2.1
1412 +
1413 + camera-controls@3.1.2(three@0.182.0):
1414 + dependencies:
1415 + three: 0.182.0
1416 +
1417 + caniuse-lite@1.0.30001810: {}
1418 +
1419 + client-only@0.0.1: {}
1420 +
1421 + commander@2.20.3: {}
1422 +
1423 + cross-env@7.0.3:
1424 + dependencies:
1425 + cross-spawn: 7.0.6
1426 +
1427 + cross-spawn@7.0.6:
1428 + dependencies:
1429 + path-key: 3.1.1
1430 + shebang-command: 2.0.0
1431 + which: 2.0.2
1432 +
1433 + csstype@3.2.3: {}
1434 +
1435 + d3-array@3.2.4:
1436 + dependencies:
1437 + internmap: 2.0.3
1438 +
1439 + d3-color@3.1.0: {}
1440 +
1441 + d3-format@3.1.2: {}
1442 +
1443 + d3-geo@3.1.1:
1444 + dependencies:
1445 + d3-array: 3.2.4
1446 +
1447 + d3-interpolate@3.0.1:
1448 + dependencies:
1449 + d3-color: 3.1.0
1450 +
1451 + d3-path@3.1.0: {}
1452 +
1453 + d3-scale@4.0.2:
1454 + dependencies:
1455 + d3-array: 3.2.4
1456 + d3-format: 3.1.2
1457 + d3-interpolate: 3.0.1
1458 + d3-time: 3.1.0
1459 + d3-time-format: 4.1.0
1460 +
1461 + d3-shape@3.2.0:
1462 + dependencies:
1463 + d3-path: 3.1.0
1464 +
1465 + d3-time-format@4.1.0:
1466 + dependencies:
1467 + d3-time: 3.1.0
1468 +
1469 + d3-time@3.1.0:
1470 + dependencies:
1471 + d3-array: 3.2.4
1472 +
1473 + detect-gpu@5.0.70:
1474 + dependencies:
1475 + webgl-constants: 1.1.1
1476 +
1477 + detect-libc@2.1.2: {}
1478 +
1479 + draco3d@1.5.7: {}
1480 +
1481 + enhanced-resolve@5.24.5:
1482 + dependencies:
1483 + graceful-fs: 4.2.11
1484 + tapable: 2.3.3
1485 +
1486 + fflate@0.6.11: {}
1487 +
1488 + fflate@0.8.3: {}
1489 +
1490 + glsl-noise@0.0.0: {}
1491 +
1492 + graceful-fs@4.2.11: {}
1493 +
1494 + hls.js@1.7.2: {}
1495 +
1496 + ieee754@1.2.1: {}
1497 +
1498 + immediate@3.0.6: {}
1499 +
1500 + internmap@2.0.3: {}
1501 +
1502 + is-promise@2.2.2: {}
1503 +
1504 + isexe@2.0.0: {}
1505 +
1506 + its-fine@2.0.0(@types/react@19.3.0)(react@19.2.8):
1507 + dependencies:
1508 + '@types/react-reconciler': 0.28.9(@types/react@19.3.0)
1509 + react: 19.2.8
1510 + transitivePeerDependencies:
1511 + - '@types/react'
1512 +
1513 + jiti@2.7.0: {}
1514 +
1515 + lie@3.3.0:
1516 + dependencies:
1517 + immediate: 3.0.6
1518 +
1519 + lightningcss-android-arm64@1.32.0:
1520 + optional: true
1521 +
1522 + lightningcss-darwin-arm64@1.32.0:
1523 + optional: true
1524 +
1525 + lightningcss-darwin-x64@1.32.0:
1526 + optional: true
1527 +
1528 + lightningcss-freebsd-x64@1.32.0:
1529 + optional: true
1530 +
1531 + lightningcss-linux-arm-gnueabihf@1.32.0:
1532 + optional: true
1533 +
1534 + lightningcss-linux-arm64-gnu@1.32.0:
1535 + optional: true
1536 +
1537 + lightningcss-linux-arm64-musl@1.32.0:
1538 + optional: true
1539 +
1540 + lightningcss-linux-x64-gnu@1.32.0:
1541 + optional: true
1542 +
1543 + lightningcss-linux-x64-musl@1.32.0:
1544 + optional: true
1545 +
1546 + lightningcss-win32-arm64-msvc@1.32.0:
1547 + optional: true
1548 +
1549 + lightningcss-win32-x64-msvc@1.32.0:
1550 + optional: true
1551 +
1552 + lightningcss@1.32.0:
1553 + dependencies:
1554 + detect-libc: 2.1.2
1555 + optionalDependencies:
1556 + lightningcss-android-arm64: 1.32.0
1557 + lightningcss-darwin-arm64: 1.32.0
1558 + lightningcss-darwin-x64: 1.32.0
1559 + lightningcss-freebsd-x64: 1.32.0
1560 + lightningcss-linux-arm-gnueabihf: 1.32.0
1561 + lightningcss-linux-arm64-gnu: 1.32.0
1562 + lightningcss-linux-arm64-musl: 1.32.0
1563 + lightningcss-linux-x64-gnu: 1.32.0
1564 + lightningcss-linux-x64-musl: 1.32.0
1565 + lightningcss-win32-arm64-msvc: 1.32.0
1566 + lightningcss-win32-x64-msvc: 1.32.0
1567 +
1568 + lucide-react@1.44.0(react@19.2.8):
1569 + dependencies:
1570 + react: 19.2.8
1571 +
1572 + maath@0.10.8(@types/three@0.182.0)(three@0.182.0):
1573 + dependencies:
1574 + '@types/three': 0.182.0
1575 + three: 0.182.0
1576 +
1577 + magic-string@0.30.21:
1578 + dependencies:
1579 + '@jridgewell/sourcemap-codec': 1.6.0
1580 +
1581 + meshline@3.3.1(three@0.182.0):
1582 + dependencies:
1583 + three: 0.182.0
1584 +
1585 + meshoptimizer@0.22.0: {}
1586 +
1587 + nanoid@3.3.19: {}
1588 +
1589 + next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
1590 + dependencies:
1591 + '@next/env': 16.3.4
1592 + '@swc/helpers': 0.5.23
1593 + baseline-browser-mapping: 2.11.22
1594 + caniuse-lite: 1.0.30001810
1595 + postcss: 8.5.23
1596 + react: 19.2.8
1597 + react-dom: 19.2.8(react@19.2.8)
1598 + styled-jsx: 5.1.6(react@19.2.8)
1599 + optionalDependencies:
1600 + '@next/swc-darwin-arm64': 16.3.4
1601 + '@next/swc-darwin-x64': 16.3.4
1602 + '@next/swc-linux-arm64-gnu': 16.3.4
1603 + '@next/swc-linux-arm64-musl': 16.3.4
1604 + '@next/swc-linux-x64-gnu': 16.3.4
1605 + '@next/swc-linux-x64-musl': 16.3.4
1606 + '@next/swc-win32-arm64-msvc': 16.3.4
1607 + '@next/swc-win32-x64-msvc': 16.3.4
1608 + sharp: 0.35.4(@types/node@24.13.4)
1609 + transitivePeerDependencies:
1610 + - '@babel/core'
1611 + - '@types/node'
1612 + - babel-plugin-macros
1613 +
1614 + path-key@3.1.1: {}
1615 +
1616 + picocolors@1.1.1: {}
1617 +
1618 + postcss@8.5.23:
1619 + dependencies:
1620 + nanoid: 3.3.19
1621 + picocolors: 1.1.1
1622 + source-map-js: 1.2.1
1623 +
1624 + postcss@8.5.28:
1625 + dependencies:
1626 + nanoid: 3.3.19
1627 + picocolors: 1.1.1
1628 + source-map-js: 1.2.1
1629 +
1630 + potpack@1.0.2: {}
1631 +
1632 + promise-worker-transferable@1.0.4:
1633 + dependencies:
1634 + is-promise: 2.2.2
1635 + lie: 3.3.0
1636 +
1637 + react-dom@19.2.8(react@19.2.8):
1638 + dependencies:
1639 + react: 19.2.8
1640 + scheduler: 0.27.0
1641 +
1642 + react-use-measure@2.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
1643 + dependencies:
1644 + react: 19.2.8
1645 + optionalDependencies:
1646 + react-dom: 19.2.8(react@19.2.8)
1647 +
1648 + react@19.2.8: {}
1649 +
1650 + require-from-string@2.0.2: {}
1651 +
1652 + scheduler@0.27.0: {}
1653 +
1654 + semver@7.8.5:
1655 + optional: true
1656 +
1657 + server-only@0.0.1: {}
1658 +
1659 + sharp@0.35.4(@types/node@24.13.4):
1660 + dependencies:
1661 + '@img/colour': 1.1.0
1662 + detect-libc: 2.1.2
1663 + semver: 7.8.5
1664 + optionalDependencies:
1665 + '@img/sharp-darwin-arm64': 0.35.4
1666 + '@img/sharp-darwin-x64': 0.35.4
1667 + '@img/sharp-freebsd-wasm32': 0.35.4
1668 + '@img/sharp-libvips-darwin-arm64': 1.3.3
1669 + '@img/sharp-libvips-darwin-x64': 1.3.3
1670 + '@img/sharp-libvips-linux-arm': 1.3.3
1671 + '@img/sharp-libvips-linux-arm64': 1.3.3
1672 + '@img/sharp-libvips-linux-ppc64': 1.3.3
1673 + '@img/sharp-libvips-linux-riscv64': 1.3.3
1674 + '@img/sharp-libvips-linux-s390x': 1.3.3
1675 + '@img/sharp-libvips-linux-x64': 1.3.3
1676 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
1677 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
1678 + '@img/sharp-linux-arm': 0.35.4
1679 + '@img/sharp-linux-arm64': 0.35.4
1680 + '@img/sharp-linux-ppc64': 0.35.4
1681 + '@img/sharp-linux-riscv64': 0.35.4
1682 + '@img/sharp-linux-s390x': 0.35.4
1683 + '@img/sharp-linux-x64': 0.35.4
1684 + '@img/sharp-linuxmusl-arm64': 0.35.4
1685 + '@img/sharp-linuxmusl-x64': 0.35.4
1686 + '@img/sharp-webcontainers-wasm32': 0.35.4
1687 + '@img/sharp-win32-arm64': 0.35.4
1688 + '@img/sharp-win32-ia32': 0.35.4
1689 + '@img/sharp-win32-x64': 0.35.4
1690 + '@types/node': 24.13.4
1691 + optional: true
1692 +
1693 + shebang-command@2.0.0:
1694 + dependencies:
1695 + shebang-regex: 3.0.0
1696 +
1697 + shebang-regex@3.0.0: {}
1698 +
1699 + source-map-js@1.2.1: {}
1700 +
1701 + stats-gl@2.4.2(@types/three@0.182.0)(three@0.182.0):
1702 + dependencies:
1703 + '@types/three': 0.182.0
1704 + three: 0.182.0
1705 +
1706 + stats.js@0.17.0: {}
1707 +
1708 + styled-jsx@5.1.6(react@19.2.8):
1709 + dependencies:
1710 + client-only: 0.0.1
1711 + react: 19.2.8
1712 +
1713 + suspend-react@0.1.3(react@19.2.8):
1714 + dependencies:
1715 + react: 19.2.8
1716 +
1717 + tailwindcss@4.3.3: {}
1718 +
1719 + tapable@2.3.3: {}
1720 +
1721 + three-mesh-bvh@0.8.3(three@0.182.0):
1722 + dependencies:
1723 + three: 0.182.0
1724 +
1725 + three-stdlib@2.36.1(three@0.182.0):
1726 + dependencies:
1727 + '@types/draco3d': 1.4.10
1728 + '@types/offscreencanvas': 2019.7.3
1729 + '@types/webxr': 0.5.24
1730 + draco3d: 1.5.7
1731 + fflate: 0.6.11
1732 + potpack: 1.0.2
1733 + three: 0.182.0
1734 +
1735 + three@0.182.0: {}
1736 +
1737 + topojson-client@3.1.0:
1738 + dependencies:
1739 + commander: 2.20.3
1740 +
1741 + troika-three-text@0.52.5(three@0.182.0):
1742 + dependencies:
1743 + bidi-js: 1.1.0
1744 + three: 0.182.0
1745 + troika-three-utils: 0.52.5(three@0.182.0)
1746 + troika-worker-utils: 0.52.0
1747 + webgl-sdf-generator: 1.1.1
1748 +
1749 + troika-three-utils@0.52.5(three@0.182.0):
1750 + dependencies:
1751 + three: 0.182.0
1752 +
1753 + troika-worker-utils@0.52.0: {}
1754 +
1755 + tslib@2.8.1: {}
1756 +
1757 + tunnel-rat@0.1.2(@types/react@19.3.0)(react@19.2.8):
1758 + dependencies:
1759 + zustand: 4.5.7(@types/react@19.3.0)(react@19.2.8)
1760 + transitivePeerDependencies:
1761 + - '@types/react'
1762 + - immer
1763 + - react
1764 +
1765 + typescript@5.9.3: {}
1766 +
1767 + undici-types@7.18.2: {}
1768 +
1769 + use-sync-external-store@1.7.0(react@19.2.8):
1770 + dependencies:
1771 + react: 19.2.8
1772 +
1773 + utility-types@3.11.0: {}
1774 +
1775 + webgl-constants@1.1.1: {}
1776 +
1777 + webgl-sdf-generator@1.1.1: {}
1778 +
1779 + which@2.0.2:
1780 + dependencies:
1781 + isexe: 2.0.0
1782 +
1783 + world-atlas@2.0.2: {}
1784 +
1785 + zustand@4.5.7(@types/react@19.3.0)(react@19.2.8):
1786 + dependencies:
1787 + use-sync-external-store: 1.7.0(react@19.2.8)
1788 + optionalDependencies:
1789 + '@types/react': 19.3.0
1790 + react: 19.2.8
1791 +
1792 + zustand@5.0.15(@types/react@19.3.0)(react@19.2.8)(use-sync-external-store@1.7.0(react@19.2.8)):
1793 + optionalDependencies:
1794 + '@types/react': 19.3.0
1795 + react: 19.2.8
1796 + use-sync-external-store: 1.7.0(react@19.2.8)
added pnpm-workspace.yaml +2 −0
@@ -0,0 +1,2 @@
1 +packages:
2 + - apps/*
added pyproject.toml +49 −0
@@ -0,0 +1,49 @@
1 +[project]
2 +name = "satelliteindex"
3 +version = "0.1.0"
4 +description = "SatelliteIndex.io — global orbital infrastructure intelligence platform: connectors, canonical database, orbital service and API"
5 +requires-python = ">=3.12"
6 +dependencies = [
7 + "fastapi>=0.115",
8 + "uvicorn[standard]>=0.30",
9 + "pydantic>=2.8",
10 + "pydantic-settings>=2.4",
11 + "sqlalchemy[asyncio]>=2.0.35",
12 + "asyncpg>=0.30",
13 + "alembic>=1.13",
14 + "httpx>=0.27",
15 + "orjson>=3.10",
16 + "typer>=0.12",
17 + "rich>=13",
18 + "sgp4>=2.23",
19 + "numpy>=2",
20 + "redis>=5.1",
21 + "apscheduler>=3.10,<4",
22 + "python-ulid>=2.7",
23 + "pyyaml>=6",
24 + "python-dateutil>=2.9",
25 + "tenacity>=9",
26 + "python-slugify>=8",
27 +]
28 +
29 +[project.optional-dependencies]
30 +dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6", "respx>=0.21", "httpx>=0.27"]
31 +
32 +[project.scripts]
33 +si = "satelliteindex.cli:app"
34 +
35 +[build-system]
36 +requires = ["hatchling"]
37 +build-backend = "hatchling.build"
38 +
39 +[tool.hatch.build.targets.wheel]
40 +packages = ["src/satelliteindex"]
41 +
42 +[tool.ruff]
43 +line-length = 120
44 +target-version = "py312"
45 +
46 +[tool.pytest.ini_options]
47 +testpaths = ["tests"]
48 +asyncio_mode = "auto"
49 +markers = ["live: hits real external endpoints (skipped unless -m live)"]
added scripts/backup-offnode.sh +11 −0
@@ -0,0 +1,11 @@
1 +#!/bin/bash
2 +# Copy the newest database dumps from the production node to the gateway (off-node backup). Run from the laptop or M1M32.
3 +set -euo pipefail
4 +NODE=${1:-M2M32b}
5 +DEST=${2:-M1M32:~/backups/satelliteindex/}
6 +ssh "${DEST%%:*}" "mkdir -p ${DEST#*:}"
7 +ssh "$NODE" 'ls -1t ~/satelliteindex-data/backups/satelliteindex-*.dump 2>/dev/null | head -3' | while read -r f; do
8 + echo "copy $NODE:$f → $DEST"
9 + ssh "$NODE" "cat $f" | ssh "${DEST%%:*}" "cat > ${DEST#*:}$(basename "$f")"
10 +done
11 +ssh "${DEST%%:*}" "ls -la ${DEST#*:} | tail -5"
added src/satelliteindex/__init__.py +3 −0
@@ -0,0 +1,3 @@
1 +"""SatelliteIndex — the world's orbital infrastructure, mapped and indexed."""
2 +
3 +__version__ = "0.1.0"
added src/satelliteindex/analytics/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Derived analytics: materialized views, stats snapshots, search index, freshness. Never computed per page request."""
added src/satelliteindex/analytics/search.py +81 −0
@@ -0,0 +1,81 @@
1 +"""Unified search index (PostgreSQL full text + trigram). Rebuilt incrementally after ingestion."""
2 +from __future__ import annotations
3 +
4 +import logging
5 +
6 +from sqlalchemy.ext.asyncio import AsyncConnection
7 +
8 +from satelliteindex.db import execute
9 +
10 +log = logging.getLogger(__name__)
11 +
12 +
13 +async def rebuild_search_index(conn: AsyncConnection) -> None:
14 + # satellites (on-orbit or recently decayed first through weight)
15 + await execute(conn, """
16 + insert into search_index (entity_type, entity_id, slug, title, subtitle, keywords, weight, tsv, updated_at)
17 + select 'satellite', s.id, s.slug, s.canonical_name,
18 + concat_ws(' · ', nullif(s.object_type,''), case when s.norad_id is not null then 'NORAD ' || s.norad_id end, s.cospar_id, o.name, c.name),
19 + concat_ws(' ', s.norad_id::text, s.cospar_id, replace(coalesce(s.cospar_id,''),'-',''), o.name, k.name, c.name,
20 + (select string_agg(a.alias, ' ') from satellite_aliases a where a.satellite_id = s.id)),
21 + case when s.status = 'ACTIVE' then 3.0 when s.decay_date is null then 2.0 else 1.0 end
22 + + case when s.object_type = 'STATION' then 5 when s.object_type in ('PAYLOAD') then 1 else 0 end,
23 + setweight(to_tsvector('simple', s.canonical_name), 'A') ||
24 + setweight(to_tsvector('simple', coalesce(s.norad_id::text,'') || ' ' || coalesce(s.cospar_id,'')), 'A') ||
25 + setweight(to_tsvector('simple', coalesce(o.name,'') || ' ' || coalesce(k.name,'') || ' ' || coalesce(c.name,'')), 'C'),
26 + now()
27 + from satellites s
28 + left join organizations o on o.id = s.operator_id
29 + left join constellations k on k.id = s.constellation_id
30 + left join countries c on c.code = s.country_code
31 + where s.updated_at >= coalesce((select max(updated_at) from search_index where entity_type = 'satellite'), '1970-01-01'::timestamptz) - interval '10 minutes'
32 + or not exists (select 1 from search_index si where si.entity_type = 'satellite' and si.entity_id = s.id)
33 + on conflict (entity_type, entity_id) do update set slug = excluded.slug, title = excluded.title, subtitle = excluded.subtitle,
34 + keywords = excluded.keywords, weight = excluded.weight, tsv = excluded.tsv, updated_at = now()""")
35 + await execute(conn, """
36 + insert into search_index (entity_type, entity_id, slug, title, subtitle, keywords, weight, tsv, updated_at)
37 + select 'operator', o.id, o.slug, o.name, concat_ws(' · ', initcap(replace(o.kind,'_',' ')), c.name, os.active_payloads || ' active'),
38 + concat_ws(' ', (select string_agg(a.alias, ' ') from organization_aliases a where a.organization_id = o.id), c.name),
39 + 8.0 + coalesce(os.active_payloads, 0) / 100.0,
40 + setweight(to_tsvector('simple', o.name), 'A') || setweight(to_tsvector('simple', coalesce((select string_agg(a.alias, ' ') from organization_aliases a where a.organization_id = o.id),'')), 'B'),
41 + now()
42 + from organizations o left join countries c on c.code = o.country_code left join operator_stats os on os.id = o.id
43 + on conflict (entity_type, entity_id) do update set slug = excluded.slug, title = excluded.title, subtitle = excluded.subtitle,
44 + keywords = excluded.keywords, weight = excluded.weight, tsv = excluded.tsv, updated_at = now()""")
45 + await execute(conn, """
46 + insert into search_index (entity_type, entity_id, slug, title, subtitle, keywords, weight, tsv, updated_at)
47 + select 'constellation', k.id, k.slug, k.name, concat_ws(' · ', o.name, k.service_type, k.orbit_class, ks.active || ' active'),
48 + concat_ws(' ', o.name, k.service_type, k.orbit_class, c.name), 9.0 + coalesce(ks.active, 0) / 100.0,
49 + setweight(to_tsvector('simple', k.name), 'A') || setweight(to_tsvector('simple', coalesce(o.name,'') || ' ' || coalesce(k.service_type,'')), 'B'),
50 + now()
51 + from constellations k left join organizations o on o.id = k.operator_id left join countries c on c.code = k.country_code left join constellation_stats ks on ks.id = k.id
52 + on conflict (entity_type, entity_id) do update set slug = excluded.slug, title = excluded.title, subtitle = excluded.subtitle,
53 + keywords = excluded.keywords, weight = excluded.weight, tsv = excluded.tsv, updated_at = now()""")
54 + await execute(conn, """
55 + insert into search_index (entity_type, entity_id, slug, title, subtitle, keywords, weight, tsv, updated_at)
56 + select 'country', c.code, c.slug, c.name, concat_ws(' · ', c.region, cs.active_payloads || ' active satellites'),
57 + concat_ws(' ', c.code, c.iso3, c.region), 9.0 + coalesce(cs.active_payloads, 0) / 100.0,
58 + setweight(to_tsvector('simple', c.name), 'A') || setweight(to_tsvector('simple', coalesce(c.code,'') || ' ' || coalesce(c.iso3,'')), 'B'),
59 + now()
60 + from countries c left join country_stats cs on cs.code = c.code
61 + on conflict (entity_type, entity_id) do update set slug = excluded.slug, title = excluded.title, subtitle = excluded.subtitle,
62 + keywords = excluded.keywords, weight = excluded.weight, tsv = excluded.tsv, updated_at = now()""")
63 + await execute(conn, """
64 + insert into search_index (entity_type, entity_id, slug, title, subtitle, keywords, weight, tsv, updated_at)
65 + select 'launch_site', ls.code, ls.slug, ls.name, concat_ws(' · ', c.name, 'launch site'), concat_ws(' ', ls.code, c.name), 7.0,
66 + setweight(to_tsvector('simple', ls.name), 'A') || setweight(to_tsvector('simple', ls.code), 'B'), now()
67 + from launch_sites ls left join countries c on c.code = ls.country_code
68 + on conflict (entity_type, entity_id) do update set slug = excluded.slug, title = excluded.title, subtitle = excluded.subtitle,
69 + keywords = excluded.keywords, weight = excluded.weight, tsv = excluded.tsv, updated_at = now()""")
70 + await execute(conn, """
71 + insert into search_index (entity_type, entity_id, slug, title, subtitle, keywords, weight, tsv, updated_at)
72 + select 'launch', l.id, l.cospar_launch_id, coalesce(l.primary_name, 'Launch ' || l.cospar_launch_id) || ' (' || l.cospar_launch_id || ')',
73 + concat_ws(' · ', l.launch_date::text, ls.name, l.payload_count || ' payloads'),
74 + concat_ws(' ', l.cospar_launch_id, replace(l.cospar_launch_id,'-',''), ls.name), 2.0,
75 + setweight(to_tsvector('simple', l.cospar_launch_id || ' ' || coalesce(l.primary_name,'')), 'A'), now()
76 + from launches l left join launch_sites ls on ls.code = l.launch_site_code
77 + where l.updated_at >= coalesce((select max(updated_at) from search_index where entity_type = 'launch'), '1970-01-01'::timestamptz) - interval '10 minutes'
78 + or not exists (select 1 from search_index si where si.entity_type = 'launch' and si.entity_id = l.id)
79 + on conflict (entity_type, entity_id) do update set slug = excluded.slug, title = excluded.title, subtitle = excluded.subtitle,
80 + keywords = excluded.keywords, weight = excluded.weight, tsv = excluded.tsv, updated_at = now()""")
81 + log.info("search index rebuilt")
added src/satelliteindex/analytics/stats.py +88 −0
@@ -0,0 +1,88 @@
1 +"""Global statistics snapshot + materialized view refresh."""
2 +from __future__ import annotations
3 +
4 +import json
5 +import logging
6 +from datetime import UTC, datetime
7 +from typing import Any
8 +
9 +from sqlalchemy.ext.asyncio import AsyncConnection
10 +
11 +from satelliteindex.db import execute, fetch_all, fetch_one
12 +
13 +log = logging.getLogger(__name__)
14 +
15 +MATVIEWS = ("country_stats", "operator_stats", "constellation_stats", "launch_year_stats", "orbital_bucket_stats")
16 +
17 +
18 +async def refresh_matviews(conn: AsyncConnection) -> None:
19 + for mv in MATVIEWS:
20 + await execute(conn, f"refresh materialized view concurrently {mv}")
21 +
22 +
23 +async def compute_global_stats(conn: AsyncConnection) -> dict[str, Any]:
24 + g = await fetch_one(conn, """
25 + select
26 + count(*) filter (where status = 'ACTIVE' and object_type in ('PAYLOAD','STATION')) as active_satellites,
27 + count(*) filter (where decay_date is null) as objects_on_orbit,
28 + count(*) as objects_catalogued,
29 + count(*) filter (where object_type in ('PAYLOAD','STATION')) as payloads_total,
30 + count(*) filter (where object_type in ('PAYLOAD','STATION') and decay_date is null) as payloads_on_orbit,
31 + count(*) filter (where object_type = 'DEBRIS' and decay_date is null) as debris_on_orbit,
32 + count(*) filter (where object_type = 'ROCKET_BODY' and decay_date is null) as rocket_bodies_on_orbit,
33 + count(*) filter (where decay_date is not null) as decayed_objects,
34 + count(*) filter (where decay_date >= current_date - 30) as decayed_last_30d,
35 + count(*) filter (where decay_date >= current_date - 365) as decayed_last_365d,
36 + count(*) filter (where has_gp) as with_elements,
37 + count(*) filter (where launch_date >= current_date - 30 and object_type in ('PAYLOAD','STATION')) as payloads_launched_30d,
38 + count(*) filter (where launch_date >= current_date - 365 and object_type in ('PAYLOAD','STATION')) as payloads_launched_365d,
39 + count(*) filter (where launch_date >= date_trunc('year', current_date) and object_type in ('PAYLOAD','STATION')) as payloads_launched_ytd,
40 + count(distinct operator_id) filter (where status = 'ACTIVE') as active_operators,
41 + count(distinct country_code) filter (where status = 'ACTIVE') as active_countries,
42 + count(distinct constellation_id) filter (where status = 'ACTIVE') as active_constellations,
43 + max(latest_epoch) as latest_epoch
44 + from satellites""")
45 + launches = await fetch_one(conn, """select count(*) as total, count(*) filter (where launch_date >= current_date - 365) as last_365d,
46 + count(*) filter (where launch_date >= date_trunc('year', current_date)) as ytd,
47 + count(*) filter (where launch_date >= current_date - 30) as last_30d from launches""")
48 + by_class = await fetch_all(conn, """select coalesce(orbit_class,'UNKNOWN') as orbit_class,
49 + count(*) filter (where status='ACTIVE' and object_type in ('PAYLOAD','STATION')) as active,
50 + count(*) filter (where decay_date is null) as on_orbit
51 + from satellites group by 1 order by 2 desc""")
52 + by_type = await fetch_all(conn, """select object_type, count(*) filter (where decay_date is null) as on_orbit, count(*) as total
53 + from satellites group by 1 order by 2 desc""")
54 + by_mission = await fetch_all(conn, """select coalesce(mission_type,'unknown') as mission_type, count(*) as active
55 + from satellites where status='ACTIVE' and object_type in ('PAYLOAD','STATION') group by 1 order by 2 desc""")
56 + by_year = await fetch_all(conn, """select launch_year as year, count(*) as launches, sum(payload_count) as payloads from launches
57 + where launch_year is not null group by 1 order by 1""")
58 + active_by_year = await fetch_all(conn, """select extract(year from launch_date)::int as year, count(*) as payloads,
59 + count(*) filter (where status = 'ACTIVE') as still_active
60 + from satellites where object_type in ('PAYLOAD','STATION') and launch_date is not null group by 1 order by 1""")
61 + decays_by_year = await fetch_all(conn, """select extract(year from decay_date)::int as year, count(*) as decayed
62 + from satellites where decay_date is not null group by 1 order by 1""")
63 + top_countries = await fetch_all(conn, "select code, name, slug, active_payloads, on_orbit_payloads, objects_on_orbit, debris_on_orbit from country_stats order by active_payloads desc nulls last limit 25")
64 + top_operators = await fetch_all(conn, "select id, slug, name, country_code, active_payloads, total_payloads, payloads_last_365d from operator_stats where active_payloads > 0 order by active_payloads desc limit 25")
65 + top_constellations = await fetch_all(conn, "select id, slug, name, operator_id, service_type, orbit_class, active, on_orbit, total, launched_last_365d, launched_last_30d from constellation_stats where total > 0 order by active desc limit 25")
66 + buckets = await fetch_all(conn, "select * from orbital_bucket_stats")
67 + freshness = await fetch_all(conn, """select c.name, c.source_id, c.last_success_at, c.last_attempt_at, c.interval_seconds, c.enabled, c.circuit_open_until,
68 + c.consecutive_failures from connectors c order by c.priority desc""")
69 + snapshot = {
70 + "computed_at": datetime.now(UTC).isoformat(),
71 + "global": g, "launches": launches, "by_orbit_class": by_class, "by_object_type": by_type, "by_mission_type": by_mission,
72 + "launches_by_year": by_year, "payloads_by_launch_year": active_by_year, "decays_by_year": decays_by_year,
73 + "top_countries": top_countries, "top_operators": top_operators, "top_constellations": top_constellations,
74 + "orbital_buckets": buckets, "connectors": freshness,
75 + }
76 + await execute(conn, """insert into stats_snapshots (key, computed_at, payload) values ('global', now(), cast(:p as jsonb))
77 + on conflict (key) do update set computed_at = now(), payload = excluded.payload""", p=json.dumps(snapshot, default=str))
78 + log.info("global stats snapshot", extra={"active": g["active_satellites"], "on_orbit": g["objects_on_orbit"]})
79 + return snapshot
80 +
81 +
82 +async def load_snapshot(conn: AsyncConnection, key: str = "global") -> dict[str, Any] | None:
83 + row = await fetch_one(conn, "select computed_at, payload from stats_snapshots where key = :k", k=key)
84 + if not row:
85 + return None
86 + payload = row["payload"] if isinstance(row["payload"], dict) else json.loads(row["payload"])
87 + payload["computed_at"] = row["computed_at"].isoformat()
88 + return payload
added src/satelliteindex/api/__init__.py +0 −0
added src/satelliteindex/api/common.py +93 −0
@@ -0,0 +1,93 @@
1 +"""Response envelopes, pagination, freshness, caching helpers and admin auth for the API."""
2 +from __future__ import annotations
3 +
4 +import hmac
5 +import math
6 +import time
7 +import uuid
8 +from collections.abc import Awaitable, Callable
9 +from datetime import UTC, datetime
10 +from typing import Any
11 +
12 +from fastapi import Depends, HTTPException, Query, Request
13 +from fastapi.responses import ORJSONResponse
14 +
15 +from satelliteindex.config import settings
16 +from satelliteindex.services.cache import cache
17 +
18 +
19 +class ApiProblem(HTTPException):
20 + """RFC 7807-ish problem (title/detail). Never carries stack traces."""
21 +
22 + def __init__(self, status: int, title: str, detail: str | None = None):
23 + super().__init__(status_code=status, detail={"title": title, "detail": detail, "status": status})
24 +
25 +
26 +def meta(request: Request | None = None, **extra: Any) -> dict[str, Any]:
27 + rid = getattr(request.state, "request_id", None) if request is not None else None
28 + return {"request_id": rid or uuid.uuid4().hex[:16], "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), **extra}
29 +
30 +
31 +def envelope(data: Any, request: Request | None = None, **extra: Any) -> dict[str, Any]:
32 + return {"data": data, "meta": meta(request, **extra)}
33 +
34 +
35 +def paginated(items: list[Any], *, page: int, page_size: int, total: int, request: Request | None = None, **extra: Any) -> dict[str, Any]:
36 + return {"data": items, "pagination": {"page": page, "page_size": page_size, "total": total, "pages": max(1, math.ceil(total / page_size))},
37 + "meta": meta(request, **extra)}
38 +
39 +
40 +class Page:
41 + def __init__(self, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200)):
42 + self.page = page
43 + self.page_size = page_size
44 +
45 + @property
46 + def offset(self) -> int:
47 + return (self.page - 1) * self.page_size
48 +
49 +
50 +def freshness_status(last_success: datetime | None, *, aging_s: int | None = None, stale_s: int | None = None) -> str:
51 + if last_success is None:
52 + return "unavailable"
53 + age = (datetime.now(UTC) - last_success).total_seconds()
54 + if age < (aging_s or settings.orbit_aging_s):
55 + return "fresh"
56 + if age < (stale_s or settings.orbit_stale_s):
57 + return "aging"
58 + return "stale"
59 +
60 +
61 +async def cached(key: str, ttl_s: int, producer: Callable[[], Awaitable[Any]]) -> Any:
62 + hit = await cache.get_json("api:" + key)
63 + if hit is not None:
64 + return hit
65 + value = await producer()
66 + await cache.set_json("api:" + key, value, ttl_s)
67 + return value
68 +
69 +
70 +def require_admin(request: Request) -> None:
71 + token = request.headers.get("x-si-admin-token") or request.query_params.get("token")
72 + expected = settings.admin_token
73 + if not expected or token is None or not hmac.compare_digest(token, expected):
74 + raise ApiProblem(401, "Unauthorized", "valid x-si-admin-token required")
75 +
76 +
77 +AdminDep = Depends(require_admin)
78 +
79 +
80 +class Timer:
81 + def __init__(self) -> None:
82 + self.t0 = time.perf_counter()
83 +
84 + @property
85 + def ms(self) -> int:
86 + return int((time.perf_counter() - self.t0) * 1000)
87 +
88 +
89 +def json_response(payload: Any, status: int = 200, cache_s: int | None = None) -> ORJSONResponse:
90 + headers = {}
91 + if cache_s:
92 + headers["Cache-Control"] = f"public, max-age={min(cache_s, 60)}, s-maxage={cache_s}, stale-while-revalidate={cache_s * 2}"
93 + return ORJSONResponse(payload, status_code=status, headers=headers)
added src/satelliteindex/api/main.py +80 −0
@@ -0,0 +1,80 @@
1 +"""FastAPI application — /api/v1. Loopback-only in production; Next.js rewrites /api/v1/* to it."""
2 +from __future__ import annotations
3 +
4 +import logging
5 +import time
6 +import uuid
7 +from contextlib import asynccontextmanager
8 +
9 +from fastapi import FastAPI, Request
10 +from fastapi.exceptions import RequestValidationError
11 +from fastapi.middleware.cors import CORSMiddleware
12 +from fastapi.middleware.gzip import GZipMiddleware
13 +from fastapi.responses import ORJSONResponse
14 +from starlette.exceptions import HTTPException as StarletteHTTPException
15 +
16 +from satelliteindex import __version__
17 +from satelliteindex.api.routers import admin, constellations, countries, events, health, launches, misc, operators, orbit, satellites, search, stats
18 +from satelliteindex.config import settings
19 +from satelliteindex.db import dispose
20 +from satelliteindex.logging import setup_logging
21 +from satelliteindex.services.cache import cache
22 +
23 +log = logging.getLogger("api")
24 +
25 +
26 +@asynccontextmanager
27 +async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
28 + setup_logging(service="si-api")
29 + settings.ensure_dirs()
30 + await cache.connect()
31 + log.info("api started", extra={"version": __version__, "env": settings.app_env})
32 + yield
33 + await cache.close()
34 + await dispose()
35 +
36 +
37 +app = FastAPI(
38 + title="SatelliteIndex API", version=__version__, lifespan=lifespan, default_response_class=ORJSONResponse,
39 + docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json",
40 + description="Public intelligence layer for objects and infrastructure in Earth orbit. Data aggregated from CelesTrak and other sources — see /api/v1/sources.",
41 +)
42 +
43 +app.add_middleware(GZipMiddleware, minimum_size=2048)
44 +app.add_middleware(CORSMiddleware, allow_origins=[settings.site_url, "https://satelliteindex.io", "http://localhost:8310", "http://127.0.0.1:8310"],
45 + allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], max_age=600)
46 +
47 +
48 +@app.middleware("http")
49 +async def request_context(request: Request, call_next): # type: ignore[no-untyped-def]
50 + rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]
51 + request.state.request_id = rid
52 + t0 = time.perf_counter()
53 + try:
54 + response = await call_next(request)
55 + except Exception: # noqa: BLE001
56 + log.exception("unhandled", extra={"request_id": rid, "route": request.url.path})
57 + return ORJSONResponse({"error": {"title": "Internal Server Error", "status": 500}, "meta": {"request_id": rid}}, status_code=500)
58 + dur = int((time.perf_counter() - t0) * 1000)
59 + response.headers["x-request-id"] = rid
60 + response.headers["x-content-type-options"] = "nosniff"
61 + response.headers["referrer-policy"] = "strict-origin-when-cross-origin"
62 + if request.url.path not in ("/health", "/ready", "/api/v1/health"):
63 + log.info("request", extra={"request_id": rid, "route": request.url.path, "status": response.status_code, "duration_ms": dur, "method": request.method})
64 + return response
65 +
66 +
67 +@app.exception_handler(StarletteHTTPException)
68 +async def http_error(request: Request, exc: StarletteHTTPException): # type: ignore[no-untyped-def]
69 + detail = exc.detail if isinstance(exc.detail, dict) else {"title": str(exc.detail), "status": exc.status_code}
70 + return ORJSONResponse({"error": detail, "meta": {"request_id": getattr(request.state, "request_id", None)}}, status_code=exc.status_code)
71 +
72 +
73 +@app.exception_handler(RequestValidationError)
74 +async def validation_error(request: Request, exc: RequestValidationError): # type: ignore[no-untyped-def]
75 + return ORJSONResponse({"error": {"title": "Validation error", "status": 422, "detail": exc.errors()[:5]},
76 + "meta": {"request_id": getattr(request.state, "request_id", None)}}, status_code=422)
77 +
78 +
79 +for r in (health, satellites, search, stats, orbit, constellations, operators, countries, launches, events, misc, admin):
80 + app.include_router(r.router)
added src/satelliteindex/api/ratelimit.py +46 −0
@@ -0,0 +1,46 @@
1 +"""IP-based sliding-window rate limiting (in-process; the API runs as one process behind the Next.js rewrite)."""
2 +from __future__ import annotations
3 +
4 +import time
5 +from collections import defaultdict, deque
6 +
7 +from fastapi import Request
8 +
9 +from satelliteindex.api.common import ApiProblem
10 +
11 +LIMITS = { # requests per 60 s window
12 + "default": 600,
13 + "search": 120,
14 + "positions": 60,
15 + "position": 240,
16 + "admin": 120,
17 +}
18 +_hits: dict[tuple[str, str], deque[float]] = defaultdict(deque)
19 +
20 +
21 +def client_ip(request: Request) -> str:
22 + fwd = request.headers.get("x-forwarded-for")
23 + if fwd:
24 + return fwd.split(",")[0].strip()
25 + return request.client.host if request.client else "unknown"
26 +
27 +
28 +def check(request: Request, bucket: str = "default") -> None:
29 + now = time.time()
30 + key = (client_ip(request), bucket)
31 + q = _hits[key]
32 + while q and q[0] < now - 60:
33 + q.popleft()
34 + if len(q) >= LIMITS.get(bucket, LIMITS["default"]):
35 + raise ApiProblem(429, "Too Many Requests", f"rate limit for {bucket}: {LIMITS.get(bucket)} / min")
36 + q.append(now)
37 + if len(_hits) > 20000: # prune idle clients
38 + for k in [k for k, v in _hits.items() if not v or v[-1] < now - 120]:
39 + _hits.pop(k, None)
40 +
41 +
42 +def limiter(bucket: str): # type: ignore[no-untyped-def]
43 + def dep(request: Request) -> None:
44 + check(request, bucket)
45 +
46 + return dep
added src/satelliteindex/api/routers/__init__.py +0 −0
added src/satelliteindex/api/routers/admin.py +177 −0
@@ -0,0 +1,177 @@
1 +"""Admin API (token protected): connectors, runs, raw payloads, data quality, review queue, manual reruns, costs."""
2 +from __future__ import annotations
3 +
4 +import gzip
5 +from typing import Any
6 +
7 +from fastapi import APIRouter, Depends, Query, Request
8 +from pydantic import BaseModel
9 +
10 +from satelliteindex.api.common import AdminDep, ApiProblem, Page, envelope, paginated
11 +from satelliteindex.api.ratelimit import limiter
12 +from satelliteindex.config import settings
13 +from satelliteindex.db import connection, execute, fetch_all, fetch_one, fetch_val, transaction
14 +from satelliteindex.services.cache import cache
15 +
16 +router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[AdminDep, Depends(limiter("admin"))])
17 +
18 +
19 +@router.get("/overview")
20 +async def overview(request: Request) -> dict[str, Any]:
21 + async with connection() as conn:
22 + connectors = await fetch_all(conn, """select c.*, s.name as source_name,
23 + (select status from connector_runs r where r.connector_name = c.name order by started_at desc limit 1) as last_status,
24 + (select count(*) from connector_errors e where e.connector_name = c.name and e.occurred_at > now() - interval '7 days') as errors_7d
25 + from connectors c join sources s on s.id = c.source_id order by c.priority desc""")
26 + runs = await fetch_all(conn, "select * from connector_runs order by started_at desc limit 30")
27 + quality = await fetch_all(conn, "select flag, count(*) as open from data_quality_flags where resolved_at is null group by 1 order by 2 desc")
28 + review = await fetch_val(conn, "select count(*) from manual_review_queue where status = 'open'")
29 + db = await fetch_one(conn, """select pg_database_size(current_database()) as db_bytes,
30 + (select count(*) from satellites) as satellites, (select count(*) from orbital_elements) as elements, (select count(*) from raw_records) as raw,
31 + (select coalesce(sum(byte_size),0) from raw_records) as raw_bytes, (select count(*) from events) as events,
32 + (select count(*) from pg_stat_activity where datname = current_database()) as db_connections""")
33 + failed_jobs = await fetch_all(conn, "select * from connector_runs where status = 'failed' order by started_at desc limit 20")
34 + return envelope({"connectors": connectors, "recent_runs": runs, "quality": quality, "review_open": int(review), "database": db, "failed_jobs": failed_jobs,
35 + "queue_depth": await cache.queue_depth(), "redis": cache.healthy}, request)
36 +
37 +
38 +@router.get("/connectors/{name}/runs")
39 +async def connector_runs(name: str, request: Request, page: Page = Depends()) -> dict[str, Any]:
40 + async with connection() as conn:
41 + total = await fetch_val(conn, "select count(*) from connector_runs where connector_name = :n", n=name)
42 + rows = await fetch_all(conn, "select * from connector_runs where connector_name = :n order by started_at desc limit :l offset :o", n=name, l=page.page_size, o=page.offset)
43 + errors = await fetch_all(conn, "select * from connector_errors where connector_name = :n order by occurred_at desc limit 20", n=name)
44 + out = paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
45 + out["errors"] = errors
46 + return out
47 +
48 +
49 +class RunRequest(BaseModel):
50 + connector: str
51 +
52 +
53 +@router.post("/connectors/{name}/run")
54 +async def trigger_run(name: str, request: Request) -> dict[str, Any]:
55 + async with connection() as conn:
56 + exists = await fetch_one(conn, "select name from connectors where name = :n", n=name)
57 + if not exists:
58 + raise ApiProblem(404, "Unknown connector", name)
59 + queued = await cache.push_job({"connector": name, "requested_by": "admin"})
60 + return envelope({"queued": queued, "connector": name, "note": None if queued else "Redis unavailable: job queued in-process only (scheduler will not see it)"}, request)
61 +
62 +
63 +class ToggleRequest(BaseModel):
64 + enabled: bool
65 +
66 +
67 +@router.post("/connectors/{name}/enabled")
68 +async def toggle(name: str, body: ToggleRequest, request: Request) -> dict[str, Any]:
69 + async with transaction() as conn:
70 + n = await execute(conn, "update connectors set enabled = :e, circuit_open_until = null, consecutive_failures = 0, updated_at = now() where name = :n", e=body.enabled, n=name)
71 + if not n:
72 + raise ApiProblem(404, "Unknown connector", name)
73 + return envelope({"connector": name, "enabled": body.enabled}, request)
74 +
75 +
76 +@router.get("/raw")
77 +async def raw_records(request: Request, page: Page = Depends(), connector: str | None = None) -> dict[str, Any]:
78 + where, params = ["true"], {}
79 + if connector:
80 + where.append("connector_name = :c"); params["c"] = connector
81 + async with connection() as conn:
82 + total = await fetch_val(conn, f"select count(*) from raw_records where {' and '.join(where)}", **params)
83 + rows = await fetch_all(conn, f"select * from raw_records where {' and '.join(where)} order by fetched_at desc limit :l offset :o", **params, l=page.page_size, o=page.offset)
84 + return paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
85 +
86 +
87 +@router.get("/raw/{raw_id}")
88 +async def raw_payload(raw_id: str, request: Request, max_bytes: int = Query(200_000, le=2_000_000)) -> dict[str, Any]:
89 + async with connection() as conn:
90 + row = await fetch_one(conn, "select * from raw_records where id = :i", i=raw_id)
91 + if row is None:
92 + raise ApiProblem(404, "Raw record not found", raw_id)
93 + preview = None
94 + if row["storage_path"]:
95 + path = settings.raw_dir / row["storage_path"]
96 + if path.exists():
97 + with gzip.open(path, "rb") as fh:
98 + preview = fh.read(max_bytes).decode("utf-8", errors="replace")
99 + return envelope({**row, "preview": preview, "truncated": preview is not None and len(preview.encode()) >= max_bytes}, request)
100 +
101 +
102 +@router.get("/data-quality")
103 +async def data_quality(request: Request, flag: str | None = None, page: Page = Depends()) -> dict[str, Any]:
104 + where, params = ["f.resolved_at is null"], {}
105 + if flag:
106 + where.append("f.flag = :flag"); params["flag"] = flag
107 + async with connection() as conn:
108 + summary = await fetch_all(conn, "select flag, count(*) as open from data_quality_flags where resolved_at is null group by 1 order by 2 desc")
109 + total = await fetch_val(conn, f"select count(*) from data_quality_flags f where {' and '.join(where)}", **params)
110 + rows = await fetch_all(conn, f"""select f.*, s.slug, s.canonical_name as name, s.norad_id from data_quality_flags f left join satellites s on s.id = f.entity_id and f.entity_type = 'satellite'
111 + where {' and '.join(where)} order by f.created_at desc limit :l offset :o""", **params, l=page.page_size, o=page.offset)
112 + checks = await fetch_one(conn, """select count(*) filter (where norad_id is null) as missing_norad, count(*) filter (where cospar_id is null) as missing_cospar,
113 + count(*) filter (where operator_id is null and status = 'ACTIVE' and object_type in ('PAYLOAD','STATION')) as active_without_operator,
114 + count(*) filter (where country_code is null and status = 'ACTIVE') as active_without_country,
115 + count(*) filter (where status = 'ACTIVE' and has_gp and latest_epoch < now() - interval '30 days') as stale_active,
116 + count(*) filter (where launch_id is null and cospar_id is not null) as broken_launch_links,
117 + count(*) filter (where status = 'ACTIVE' and not has_gp) as active_without_elements from satellites""")
118 + out = paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
119 + out["summary"] = summary
120 + out["checks"] = checks
121 + return out
122 +
123 +
124 +@router.get("/review")
125 +async def review_queue(request: Request, page: Page = Depends(), status: str = "open") -> dict[str, Any]:
126 + async with connection() as conn:
127 + total = await fetch_val(conn, "select count(*) from manual_review_queue where status = :s", s=status)
128 + rows = await fetch_all(conn, """select q.*, a.slug as a_slug, a.canonical_name as a_name, a.norad_id as a_norad, a.cospar_id as a_cospar, a.status as a_status,
129 + b.slug as b_slug, b.canonical_name as b_name, b.norad_id as b_norad, b.cospar_id as b_cospar, b.status as b_status
130 + from manual_review_queue q left join satellites a on a.id = q.entity_a_id left join satellites b on b.id = q.entity_b_id
131 + where q.status = :s order by q.confidence desc nulls last, q.created_at desc limit :l offset :o""", s=status, l=page.page_size, o=page.offset)
132 + return paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
133 +
134 +
135 +class ReviewDecision(BaseModel):
136 + decision: str # merged | kept_separate | dismissed
137 + by: str = "admin"
138 +
139 +
140 +@router.post("/review/{item_id}")
141 +async def review_decide(item_id: int, body: ReviewDecision, request: Request) -> dict[str, Any]:
142 + if body.decision not in ("merged", "kept_separate", "dismissed"):
143 + raise ApiProblem(422, "Invalid decision")
144 + async with transaction() as conn:
145 + item = await fetch_one(conn, "select * from manual_review_queue where id = :i", i=item_id)
146 + if item is None:
147 + raise ApiProblem(404, "Review item not found")
148 + if body.decision == "merged" and item["entity_b_id"]:
149 + # merge B into A: aliases, identifiers, tags, elements move; B row is kept as tombstone via slug alias
150 + a, b = item["entity_a_id"], item["entity_b_id"]
151 + snap = await fetch_one(conn, "select row_to_json(s) as s from satellites s where id = :b", b=b)
152 + await execute(conn, "update satellite_aliases set satellite_id = :a where satellite_id = :b and normalized not in (select normalized from satellite_aliases where satellite_id = :a)", a=a, b=b)
153 + await execute(conn, "delete from satellite_aliases where satellite_id = :b", b=b)
154 + await execute(conn, "update satellite_slugs set satellite_id = :a where satellite_id = :b", a=a, b=b)
155 + await execute(conn, """insert into entity_identifiers (entity_type, entity_id, source_id, identifier_type, identifier_value, confidence, verified, metadata)
156 + select entity_type, :a, source_id, identifier_type, identifier_value, confidence, verified, metadata from entity_identifiers
157 + where entity_type = 'satellite' and entity_id = :b on conflict do nothing""", a=a, b=b)
158 + await execute(conn, "delete from entity_identifiers where entity_type = 'satellite' and entity_id = :b", b=b)
159 + await execute(conn, "update orbital_elements set satellite_id = :a where satellite_id = :b and epoch not in (select epoch from orbital_elements where satellite_id = :a)", a=a, b=b)
160 + await execute(conn, "delete from orbital_state where satellite_id = :b", b=b)
161 + await execute(conn, "delete from satellites where id = :b", b=b)
162 + await execute(conn, "insert into entity_merges (entity_type, kept_id, merged_id, reason, performed_by, snapshot) values ('satellite', :a, :b, 'manual review', :by, cast(:snap as jsonb))",
163 + a=a, b=b, by=body.by, snap=__import__("json").dumps(snap["s"] if snap else None, default=str))
164 + await execute(conn, "update manual_review_queue set status = :d, resolved_at = now(), resolved_by = :by where id = :i", d=body.decision, by=body.by, i=item_id)
165 + await cache.invalidate_prefix("api:")
166 + return envelope({"id": item_id, "decision": body.decision}, request)
167 +
168 +
169 +@router.get("/costs")
170 +async def costs(request: Request) -> dict[str, Any]:
171 + async with connection() as conn:
172 + per_connector = await fetch_all(conn, """select connector_name, count(*) as runs_30d, sum(records_fetched) as records, sum(duration_ms) as total_ms,
173 + count(*) filter (where status = 'failed') as failed from connector_runs where started_at > now() - interval '30 days' group by 1 order by 1""")
174 + storage = await fetch_one(conn, """select pg_size_pretty(pg_database_size(current_database())) as database, (select pg_size_pretty(pg_total_relation_size('orbital_elements'))) as orbital_elements,
175 + (select pg_size_pretty(pg_total_relation_size('satellites'))) as satellites, (select pg_size_pretty(coalesce(sum(byte_size),0)::bigint) from raw_records) as raw_uncompressed""")
176 + raw_growth = await fetch_all(conn, "select fetched_at::date as day, count(*) as snapshots, sum(byte_size) as bytes from raw_records where fetched_at > now() - interval '30 days' group by 1 order by 1")
177 + return envelope({"per_connector": per_connector, "storage": storage, "raw_growth": raw_growth, "paid_providers": {"scrapfly": "not enabled", "firecrawl": "not enabled", "llm_extraction": "not enabled"}}, request)
added src/satelliteindex/api/routers/constellations.py +71 −0
@@ -0,0 +1,71 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from fastapi import APIRouter, Depends, Request
6 +
7 +from satelliteindex.api.common import ApiProblem, Page, cached, envelope, paginated
8 +from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
9 +
10 +router = APIRouter(prefix="/api/v1", tags=["constellations"])
11 +
12 +CONST_COLS = """k.id, k.slug, k.name, k.service_type, k.orbit_class, k.lifecycle_stage, k.description, k.official_url, k.planned_count, k.authorized_count,
13 + k.country_code, c.name as country_name, c.slug as country_slug, k.operator_id, o.name as operator_name, o.slug as operator_slug,
14 + ks.active, ks.inactive, ks.decayed, ks.on_orbit, ks.total, ks.launched_last_365d, ks.launched_last_30d, ks.launches, ks.first_launch, ks.last_launch,
15 + ks.median_perigee_km, ks.median_inclination_deg,
16 + least(100, round((100.0 * ks.launched_last_30d / greatest(ks.active,1) + 10.0 * ks.launched_last_365d / greatest(ks.active,1))::numeric, 1)) as activity_score"""
17 +CONST_FROM = """from constellations k left join constellation_stats ks on ks.id = k.id left join organizations o on o.id = k.operator_id left join countries c on c.code = k.country_code"""
18 +
19 +
20 +@router.get("/constellations")
21 +async def list_constellations(request: Request, page: Page = Depends(), service: str | None = None, orbit: str | None = None, sort: str = "active") -> dict[str, Any]:
22 + where, params = ["coalesce(ks.total,0) > 0"], {}
23 + if service:
24 + where.append("k.service_type = :service"); params["service"] = service
25 + if orbit:
26 + where.append("k.orbit_class = :orbit"); params["orbit"] = orbit.upper()
27 + order = {"active": "ks.active desc nulls last", "total": "ks.total desc", "growth": "ks.launched_last_365d desc", "name": "k.name", "activity": "activity_score desc nulls last"}.get(sort, "ks.active desc nulls last")
28 + async with connection() as conn:
29 + total = await fetch_val(conn, f"select count(*) {CONST_FROM} where {' and '.join(where)}", **params)
30 + rows = await fetch_all(conn, f"select {CONST_COLS} {CONST_FROM} where {' and '.join(where)} order by {order}, k.name limit :l offset :o", **params, l=page.page_size, o=page.offset)
31 + return paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
32 +
33 +
34 +@router.get("/constellations/{slug}")
35 +async def constellation_detail(slug: str, request: Request) -> dict[str, Any]:
36 + async def produce() -> dict[str, Any]:
37 + async with connection() as conn:
38 + k = await fetch_one(conn, f"select {CONST_COLS} {CONST_FROM} where k.slug = :s", s=slug)
39 + if k is None:
40 + raise ApiProblem(404, "Constellation not found", slug)
41 + kid = k["id"]
42 + status_dist = await fetch_all(conn, "select status, count(*) as count from satellites where constellation_id = :k group by 1 order by 2 desc", k=kid)
43 + growth = await fetch_all(conn, """with m as (select date_trunc('month', launch_date)::date as month, count(*) as launched
44 + from satellites where constellation_id = :k and launch_date is not null group by 1)
45 + select month, launched, sum(launched) over (order by month) as cumulative from m order by month""", k=kid)
46 + shells = await fetch_all(conn, """select round(perigee_km / 10) * 10 as perigee_km, round(inclination_deg::numeric, 0) as inclination_deg, count(*) as satellites
47 + from satellites where constellation_id = :k and status = 'ACTIVE' and perigee_km is not null group by 1, 2 having count(*) >= 3 order by 3 desc limit 40""", k=kid)
48 + alt_hist = await fetch_all(conn, """select floor(perigee_km / 25) * 25 as alt_km, count(*) as satellites from satellites
49 + where constellation_id = :k and decay_date is null and perigee_km is not null group by 1 order by 1""", k=kid)
50 + incl_hist = await fetch_all(conn, """select round(inclination_deg::numeric, 0) as incl_deg, count(*) as satellites from satellites
51 + where constellation_id = :k and decay_date is null and inclination_deg is not null group by 1 order by 1""", k=kid)
52 + launches = await fetch_all(conn, """select l.id, l.cospar_launch_id, l.launch_date, ls.name as site_name, ls.slug as site_slug, count(s.id) as satellites,
53 + count(s.id) filter (where s.status = 'ACTIVE') as active from satellites s join launches l on l.id = s.launch_id
54 + left join launch_sites ls on ls.code = l.launch_site_code where s.constellation_id = :k group by l.id, l.cospar_launch_id, l.launch_date, ls.name, ls.slug
55 + order by l.launch_date desc nulls last limit 30""", k=kid)
56 + sites = await fetch_all(conn, """select ls.code, ls.name, ls.slug, ls.country_code, count(distinct s.launch_id) as launches, count(*) as satellites
57 + from satellites s join launch_sites ls on ls.code = s.launch_site_code where s.constellation_id = :k group by 1,2,3,4 order by 5 desc""", k=kid)
58 + countries = await fetch_all(conn, """select c.code, c.name, c.slug, count(*) as satellites from satellites s join countries c on c.code = s.country_code
59 + where s.constellation_id = :k group by 1,2,3 order by 4 desc""", k=kid)
60 + recent = await fetch_all(conn, """select id, slug, canonical_name as name, norad_id, status, launch_date, perigee_km, apogee_km, inclination_deg
61 + from satellites where constellation_id = :k order by launch_date desc nulls last, norad_id desc limit 12""", k=kid)
62 + events = await fetch_all(conn, """select e.id, e.type, e.title, e.summary, e.event_time, e.confidence from events e join event_entities x on x.event_id = e.id
63 + join satellites s on s.id = x.entity_id and x.entity_type = 'satellite' where s.constellation_id = :k order by e.event_time desc limit 20""", k=kid)
64 + decays = await fetch_all(conn, """select date_trunc('month', decay_date)::date as month, count(*) as decayed from satellites where constellation_id = :k and decay_date is not null group by 1 order by 1""", k=kid)
65 + method = await fetch_all(conn, "select method, count(*) as satellites from constellation_memberships where constellation_id = :k and until is null group by 1", k=kid)
66 + groups = await fetch_one(conn, "select match_patterns, celestrak_groups from constellations where id = :k", k=kid)
67 + return {**k, "status_distribution": status_dist, "growth": growth, "shells": shells, "altitude_histogram": alt_hist, "inclination_histogram": incl_hist,
68 + "launches_list": launches, "launch_sites": sites, "countries": countries, "recent_satellites": recent, "events": events, "decays_by_month": decays,
69 + "membership_methods": method, "match_patterns": groups["match_patterns"] if groups else [], "celestrak_groups": groups["celestrak_groups"] if groups else []}
70 +
71 + return envelope(await cached(f"constellation:{slug}", 600, produce), request)
added src/satelliteindex/api/routers/countries.py +61 −0
@@ -0,0 +1,61 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from fastapi import APIRouter, Request
6 +
7 +from satelliteindex.api.common import ApiProblem, cached, envelope
8 +from satelliteindex.db import connection, fetch_all, fetch_one
9 +
10 +router = APIRouter(prefix="/api/v1", tags=["countries"])
11 +
12 +
13 +@router.get("/countries")
14 +async def list_countries(request: Request, sort: str = "active") -> dict[str, Any]:
15 + async def produce() -> list[dict[str, Any]]:
16 + order = {"active": "active_payloads desc", "objects": "objects_on_orbit desc", "debris": "debris_on_orbit desc", "launches": "launches desc", "name": "name"}.get(sort, "active_payloads desc")
17 + async with connection() as conn:
18 + rows = await fetch_all(conn, f"""select cs.*, c.iso3, c.region, rank() over (order by cs.active_payloads desc) as rank_active
19 + from country_stats cs join countries c on c.code = cs.code where cs.total_objects > 0 order by {order}, cs.name""")
20 + return rows
21 +
22 + return envelope(await cached(f"countries:{sort}", 900, produce), request)
23 +
24 +
25 +@router.get("/countries/{ident}")
26 +async def country_detail(ident: str, request: Request) -> dict[str, Any]:
27 + async def produce() -> dict[str, Any]:
28 + async with connection() as conn:
29 + c = await fetch_one(conn, """select c.code, c.iso3, c.name, c.slug, c.region, cs.active_payloads, cs.on_orbit_payloads, cs.total_payloads, cs.debris_on_orbit,
30 + cs.rocket_bodies_on_orbit, cs.objects_on_orbit, cs.total_objects, cs.launches, cs.operators, cs.payloads_last_365d,
31 + (select count(*) + 1 from country_stats x where x.active_payloads > cs.active_payloads) as rank_active,
32 + (select count(*) + 1 from country_stats x where x.objects_on_orbit > cs.objects_on_orbit) as rank_objects,
33 + (select count(*) + 1 from country_stats x where x.debris_on_orbit > cs.debris_on_orbit) as rank_debris
34 + from countries c left join country_stats cs on cs.code = c.code where c.code = :u or c.slug = :s""", u=ident.upper(), s=ident.lower())
35 + if c is None:
36 + raise ApiProblem(404, "Country not found", ident)
37 + code = c["code"]
38 + operators = await fetch_all(conn, """select o.id, o.slug, o.name, o.kind, os.active_payloads, os.total_payloads, os.payloads_last_365d from organizations o
39 + join operator_stats os on os.id = o.id where o.country_code = :c and os.total_payloads > 0 order by os.active_payloads desc limit 30""", c=code)
40 + constellations = await fetch_all(conn, """select k.id, k.slug, k.name, k.service_type, k.orbit_class, ks.active, ks.total from constellations k join constellation_stats ks on ks.id = k.id
41 + where k.country_code = :c and ks.total > 0 order by ks.active desc limit 20""", c=code)
42 + owners = await fetch_all(conn, "select code, name, kind from owner_codes where country_code = :c order by code", c=code)
43 + orbit_dist = await fetch_all(conn, "select coalesce(orbit_class,'UNKNOWN') as orbit_class, count(*) as count from satellites where country_code = :c and decay_date is null group by 1 order by 2 desc", c=code)
44 + mission_dist = await fetch_all(conn, "select coalesce(mission_type,'unknown') as mission_type, count(*) as count from satellites where country_code = :c and status = 'ACTIVE' group by 1 order by 2 desc", c=code)
45 + type_dist = await fetch_all(conn, "select object_type, count(*) filter (where decay_date is null) as on_orbit, count(*) as total from satellites where country_code = :c group by 1 order by 2 desc", c=code)
46 + growth = await fetch_all(conn, """select extract(year from launch_date)::int as year, count(*) filter (where object_type in ('PAYLOAD','STATION')) as payloads,
47 + count(*) filter (where object_type in ('PAYLOAD','STATION') and status = 'ACTIVE') as still_active, count(distinct launch_id) as launches
48 + from satellites where country_code = :c and launch_date is not null group by 1 order by 1""", c=code)
49 + sites = await fetch_all(conn, """select ls.code, ls.name, ls.slug, ls.latitude, ls.longitude, count(l.id) as launches, max(l.launch_date) as last_launch
50 + from launch_sites ls left join launches l on l.launch_site_code = ls.code where ls.country_code = :c group by 1,2,3,4,5 order by 6 desc""", c=code)
51 + recent = await fetch_all(conn, """select id, slug, canonical_name as name, norad_id, status, object_type, orbit_class, launch_date from satellites
52 + where country_code = :c and object_type in ('PAYLOAD','STATION') order by launch_date desc nulls last, norad_id desc limit 15""", c=code)
53 + launches = await fetch_all(conn, """select l.id, l.cospar_launch_id, l.launch_date, l.payload_count, l.primary_name, ls.name as site_name, ls.slug as site_slug
54 + from launches l left join launch_sites ls on ls.code = l.launch_site_code
55 + where exists (select 1 from satellites s where s.launch_id = l.id and s.country_code = :c) order by l.launch_date desc nulls last limit 15""", c=code)
56 + events = await fetch_all(conn, """select e.id, e.type, e.title, e.summary, e.event_time from events e join event_entities x on x.event_id = e.id
57 + join satellites s on s.id = x.entity_id and x.entity_type = 'satellite' where s.country_code = :c order by e.event_time desc limit 15""", c=code)
58 + return {**c, "operators_list": operators, "constellations_list": constellations, "owner_codes": owners, "orbit_distribution": orbit_dist, "mission_distribution": mission_dist,
59 + "object_type_distribution": type_dist, "growth": growth, "launch_sites": sites, "recent_satellites": recent, "recent_launches": launches, "events": events}
60 +
61 + return envelope(await cached(f"country:{ident.lower()}", 900, produce), request)
added src/satelliteindex/api/routers/events.py +44 −0
@@ -0,0 +1,44 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from fastapi import APIRouter, Depends, Request
6 +
7 +from satelliteindex.api.common import ApiProblem, Page, envelope, paginated
8 +from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
9 +
10 +router = APIRouter(prefix="/api/v1", tags=["events"])
11 +
12 +E_SQL = """select e.id, e.type, e.title, e.summary, e.event_time, e.detected_at, e.confidence, e.source_id, src.name as source_name, e.source_url, e.metadata,
13 + (select json_agg(json_build_object('type', x.entity_type, 'id', x.entity_id, 'relationship', x.relationship,
14 + 'slug', coalesce(s.slug, l.cospar_launch_id), 'name', coalesce(s.canonical_name, l.primary_name), 'norad_id', s.norad_id))
15 + from event_entities x left join satellites s on x.entity_type = 'satellite' and s.id = x.entity_id left join launches l on x.entity_type = 'launch' and l.id = x.entity_id
16 + where x.event_id = e.id) as entities
17 + from events e left join sources src on src.id = e.source_id"""
18 +
19 +
20 +@router.get("/events")
21 +async def list_events(request: Request, page: Page = Depends(), type: str | None = None, since: str | None = None, entity: str | None = None) -> dict[str, Any]:
22 + where, params = ["true"], {}
23 + if type:
24 + where.append("e.type = any(:types)"); params["types"] = [t.upper() for t in type.split(",")]
25 + if since:
26 + where.append("e.event_time >= :since"); params["since"] = since
27 + if entity:
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 + async with connection() as conn:
30 + total = await fetch_val(conn, f"select count(*) from events e where {' and '.join(where)}", **params)
31 + rows = await fetch_all(conn, f"{E_SQL} where {' and '.join(where)} order by e.event_time desc, e.id desc limit :l offset :o", **params, l=page.page_size, o=page.offset)
32 + types = await fetch_all(conn, "select type, count(*) as count, max(event_time) as latest from events group by 1 order by 2 desc")
33 + out = paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
34 + out["types"] = types
35 + return out
36 +
37 +
38 +@router.get("/events/{event_id}")
39 +async def event_detail(event_id: str, request: Request) -> dict[str, Any]:
40 + async with connection() as conn:
41 + e = await fetch_one(conn, f"{E_SQL} where e.id = :i", i=event_id)
42 + if e is None:
43 + raise ApiProblem(404, "Event not found", event_id)
44 + return envelope(e, request)
added src/satelliteindex/api/routers/health.py +51 −0
@@ -0,0 +1,51 @@
1 +from __future__ import annotations
2 +
3 +from datetime import UTC, datetime
4 +
5 +from fastapi import APIRouter
6 +
7 +from satelliteindex import __version__
8 +from satelliteindex.api.common import freshness_status
9 +from satelliteindex.db import connection, fetch_all, fetch_val
10 +from satelliteindex.services.cache import cache
11 +from satelliteindex.services.positions import positions
12 +
13 +router = APIRouter(tags=["health"])
14 +
15 +
16 +async def _status() -> dict:
17 + out = {"status": "ok", "version": __version__, "time": datetime.now(UTC).isoformat(), "components": {}}
18 + try:
19 + async with connection() as conn:
20 + n = await fetch_val(conn, "select count(*) from satellites")
21 + conns = await fetch_all(conn, "select name, last_success_at, interval_seconds, enabled from connectors")
22 + out["components"]["database"] = {"status": "ok", "satellites": int(n)}
23 + data = {}
24 + worst = "fresh"
25 + order = {"fresh": 0, "aging": 1, "stale": 2, "unavailable": 3}
26 + for c in conns:
27 + if not c["enabled"]:
28 + continue
29 + f = freshness_status(c["last_success_at"], aging_s=c["interval_seconds"] * 3, stale_s=c["interval_seconds"] * 12)
30 + data[c["name"]] = {"last_success_at": c["last_success_at"].isoformat() if c["last_success_at"] else None, "freshness": f}
31 + if order[f] > order[worst]:
32 + worst = f
33 + out["components"]["data"] = {"status": worst, "connectors": data}
34 + except Exception as exc: # noqa: BLE001
35 + out["status"] = "degraded"
36 + out["components"]["database"] = {"status": "error", "error": exc.__class__.__name__}
37 + out["components"]["redis"] = {"status": "ok" if await cache.ping() else "degraded (in-process fallback)"}
38 + out["components"]["orbit_service"] = {"status": "ok" if positions.count else "cold", "objects": positions.count}
39 + return out
40 +
41 +
42 +@router.get("/health")
43 +@router.get("/api/v1/health")
44 +async def health() -> dict:
45 + return await _status()
46 +
47 +
48 +@router.get("/ready")
49 +async def ready() -> dict:
50 + s = await _status()
51 + return {"ready": s["components"].get("database", {}).get("status") == "ok"}
added src/satelliteindex/api/routers/launches.py +159 −0
@@ -0,0 +1,159 @@
1 +"""Launches (derived from SATCAT international designators), launch sites, debris and reentry explorers."""
2 +from __future__ import annotations
3 +
4 +from typing import Any
5 +
6 +from fastapi import APIRouter, Depends, Query, Request
7 +
8 +from satelliteindex.api.common import ApiProblem, Page, cached, envelope, paginated
9 +from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
10 +
11 +router = APIRouter(prefix="/api/v1", tags=["launches"])
12 +
13 +L_COLS = """l.id, l.cospar_launch_id, l.launch_date, l.launch_year, l.payload_count, l.object_count, l.on_orbit_count, l.primary_name, l.owner_codes,
14 + ls.code as site_code, ls.name as site_name, ls.slug as site_slug, ls.country_code as site_country, ls.latitude as site_lat, ls.longitude as site_lon"""
15 +L_FROM = "from launches l left join launch_sites ls on ls.code = l.launch_site_code"
16 +
17 +
18 +@router.get("/launches")
19 +async def list_launches(request: Request, page: Page = Depends(), year: int | None = None, site: str | None = None, country: str | None = None,
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]:
22 + where, params = ["true"], {}
23 + if year:
24 + where.append("l.launch_year = :year"); params["year"] = year
25 + if site:
26 + where.append("(ls.code = :site or ls.slug = :site)"); params["site"] = site
27 + if country:
28 + where.append("(ls.country_code = :country or exists (select 1 from satellites s where s.launch_id = l.id and s.country_code = :country))"); params["country"] = country.upper()
29 + if owner:
30 + where.append(":owner = any(l.owner_codes)"); params["owner"] = owner.upper()
31 + if min_payloads:
32 + where.append("l.payload_count >= :mp"); params["mp"] = min_payloads
33 + if after:
34 + where.append("l.launch_date >= :after"); params["after"] = after
35 + if before:
36 + where.append("l.launch_date <= :before"); params["before"] = before
37 + if q:
38 + where.append("(l.primary_name ilike :q or l.cospar_launch_id ilike :q)"); params["q"] = f"%{q}%"
39 + 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")
40 + async with connection() as conn:
41 + total = await fetch_val(conn, f"select count(*) {L_FROM} where {' and '.join(where)}", **params)
42 + rows = await fetch_all(conn, f"select {L_COLS} {L_FROM} where {' and '.join(where)} order by {order} limit :l offset :o", **params, l=page.page_size, o=page.offset)
43 + return paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
44 +
45 +
46 +@router.get("/launches/timeline")
47 +async def launches_timeline(request: Request) -> dict[str, Any]:
48 + async def produce() -> dict[str, Any]:
49 + async with connection() as conn:
50 + years = await fetch_all(conn, """select l.launch_year as year, count(*) as launches, sum(l.payload_count) as payloads,
51 + count(*) filter (where ls.country_code = 'US') as us, count(*) filter (where ls.country_code in ('RU','KZ')) as russia_cis,
52 + count(*) filter (where ls.country_code = 'CN') as china, count(*) filter (where ls.country_code not in ('US','RU','KZ','CN') or ls.country_code is null) as other
53 + from launches l left join launch_sites ls on ls.code = l.launch_site_code where l.launch_year is not null group by 1 order by 1""")
54 + months = await fetch_all(conn, """select date_trunc('month', launch_date)::date as month, count(*) as launches, sum(payload_count) as payloads from launches
55 + where launch_date >= current_date - interval '36 months' group by 1 order by 1""")
56 + sites = await fetch_all(conn, """select ls.code, ls.name, ls.slug, ls.country_code, ls.latitude, ls.longitude, count(l.id) as launches,
57 + count(l.id) filter (where l.launch_date >= current_date - 365) as launches_last_365d, max(l.launch_date) as last_launch
58 + from launch_sites ls left join launches l on l.launch_site_code = ls.code group by 1,2,3,4,5,6 having count(l.id) > 0 order by launches desc""")
59 + return {"years": years, "months": months, "sites": sites}
60 +
61 + return envelope(await cached("launches:timeline", 900, produce), request)
62 +
63 +
64 +@router.get("/launches/{cospar}")
65 +async def launch_detail(cospar: str, request: Request) -> dict[str, Any]:
66 + async with connection() as conn:
67 + l = await fetch_one(conn, f"select {L_COLS} {L_FROM} where l.cospar_launch_id = :c or l.id = :c", c=cospar.upper() if "-" in cospar else cospar)
68 + if l is None:
69 + raise ApiProblem(404, "Launch not found", cospar)
70 + objects = await fetch_all(conn, """select s.id, s.slug, s.canonical_name as name, s.norad_id, s.cospar_id, s.object_type, s.status, s.orbit_class, s.perigee_km, s.apogee_km, s.inclination_deg,
71 + s.decay_date, s.country_code, o.name as operator_name, o.slug as operator_slug, k.name as constellation_name, k.slug as constellation_slug
72 + from satellites s left join organizations o on o.id = s.operator_id left join constellations k on k.id = s.constellation_id
73 + where s.launch_id = :l order by s.object_type, s.cospar_id limit 600""", l=l["id"])
74 + owners = await fetch_all(conn, "select code, name, kind, country_code from owner_codes where code = any(:codes)", codes=l["owner_codes"] or [])
75 + events = await fetch_all(conn, """select e.id, e.type, e.title, e.summary, e.event_time from events e join event_entities x on x.event_id = e.id
76 + where (x.entity_type = 'launch' and x.entity_id = :l) or (x.entity_type = 'satellite' and x.entity_id in (select id from satellites where launch_id = :l))
77 + order by e.event_time desc limit 20""", l=l["id"])
78 + return envelope({**l, "objects": objects, "owners": owners, "events": events}, request)
79 +
80 +
81 +@router.get("/launch-sites")
82 +async def launch_sites(request: Request) -> dict[str, Any]:
83 + async def produce() -> list[dict[str, Any]]:
84 + 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,
86 + count(l.id) filter (where l.launch_date >= current_date - 365) as launches_last_365d, sum(l.payload_count) as payloads,
87 + min(l.launch_date) as first_launch, max(l.launch_date) as last_launch
88 + 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""")
90 +
91 + return envelope(await cached("launch-sites", 900, produce), request)
92 +
93 +
94 +@router.get("/launch-sites/{slug}")
95 +async def launch_site_detail(slug: str, request: Request) -> dict[str, Any]:
96 + 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())
98 + if s is None:
99 + raise ApiProblem(404, "Launch site not found", slug)
100 + 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"])
101 + recent = await fetch_all(conn, f"select {L_COLS} {L_FROM} where l.launch_site_code = :c order by l.launch_date desc nulls last limit 25", c=s["code"])
102 + owners = await fetch_all(conn, """select oc.code, oc.name, count(distinct s.launch_id) as launches from satellites s join owner_codes oc on oc.code = s.owner_code
103 + where s.launch_site_code = :c group by 1,2 order by 3 desc limit 12""", c=s["code"])
104 + return envelope({**s, "years": years, "recent_launches": recent, "owners": owners}, request)
105 +
106 +
107 +@router.get("/debris")
108 +async def debris(request: Request) -> dict[str, Any]:
109 + async def produce() -> dict[str, Any]:
110 + async with connection() as conn:
111 + totals = await fetch_one(conn, """select count(*) filter (where object_type = 'DEBRIS') as debris, count(*) filter (where object_type = 'ROCKET_BODY') as rocket_bodies,
112 + count(*) filter (where object_type = 'UNKNOWN') as unknown,
113 + count(*) filter (where object_type in ('PAYLOAD','STATION') and status <> 'ACTIVE') as inactive_payloads
114 + from satellites where decay_date is null""")
115 + by_country = await fetch_all(conn, """select c.code, c.name, c.slug, count(*) filter (where s.object_type = 'DEBRIS') as debris, count(*) filter (where s.object_type = 'ROCKET_BODY') as rocket_bodies
116 + from satellites s join countries c on c.code = s.country_code where s.decay_date is null and s.object_type in ('DEBRIS','ROCKET_BODY') group by 1,2,3 order by 4 desc limit 15""")
117 + by_alt = await fetch_all(conn, """select floor(perigee_km / 50) * 50 as alt_km, count(*) filter (where object_type = 'DEBRIS') as debris, count(*) filter (where object_type = 'ROCKET_BODY') as rocket_bodies
118 + from satellites where decay_date is null and object_type in ('DEBRIS','ROCKET_BODY') and perigee_km between 0 and 2500 group by 1 order by 1""")
119 + by_launch = await fetch_all(conn, """select l.cospar_launch_id, l.launch_date, l.primary_name, ls.name as site_name, count(*) as debris_on_orbit, l.owner_codes
120 + from satellites s join launches l on l.id = s.launch_id left join launch_sites ls on ls.code = l.launch_site_code
121 + where s.object_type = 'DEBRIS' and s.decay_date is null group by l.id, l.cospar_launch_id, l.launch_date, l.primary_name, ls.name, l.owner_codes order by 5 desc limit 15""")
122 + growth = await fetch_all(conn, """select extract(year from launch_date)::int as year, count(*) filter (where object_type = 'DEBRIS') as debris_catalogued,
123 + count(*) filter (where object_type = 'DEBRIS' and decay_date is null) as debris_still_on_orbit
124 + from satellites where launch_date is not null and object_type = 'DEBRIS' group by 1 order by 1""")
125 + decays = await fetch_all(conn, """select extract(year from decay_date)::int as year, count(*) filter (where object_type = 'DEBRIS') as debris, count(*) filter (where object_type = 'ROCKET_BODY') as rocket_bodies,
126 + count(*) filter (where object_type in ('PAYLOAD','STATION')) as payloads from satellites where decay_date is not null group by 1 order by 1""")
127 + largest = await fetch_all(conn, """select id, slug, canonical_name as name, norad_id, object_type, rcs_m2, perigee_km, apogee_km, launch_date, country_code from satellites
128 + where decay_date is null and object_type in ('DEBRIS','ROCKET_BODY') and rcs_m2 is not null order by rcs_m2 desc limit 15""")
129 + return {"totals": totals, "by_country": by_country, "by_altitude": by_alt, "by_launch": by_launch, "debris_growth": growth, "decays_by_year": decays, "largest_objects": largest,
130 + "disclaimer": "Counts of catalogued objects only; no collision probabilities are computed or implied."}
131 +
132 + return envelope(await cached("debris", 900, produce), request)
133 +
134 +
135 +@router.get("/reentries")
136 +async def reentries(request: Request, page: Page = Depends(), object_type: str | None = None, days: int = Query(90, ge=1, le=3650)) -> dict[str, Any]:
137 + where, params = ["s.decay_date is not null", "s.decay_date >= current_date - cast(:days as integer)"], {"days": days}
138 + if object_type:
139 + where.append("s.object_type = :ot"); params["ot"] = object_type.upper()
140 + async with connection() as conn:
141 + total = await fetch_val(conn, f"select count(*) from satellites s where {' and '.join(where)}", **params)
142 + rows = await fetch_all(conn, f"""select s.id, s.slug, s.canonical_name as name, s.norad_id, s.cospar_id, s.object_type, s.decay_date, s.launch_date, s.country_code, c.name as country_name,
143 + s.rcs_m2, s.perigee_km, s.apogee_km, o.name as operator_name, k.name as constellation_name, k.slug as constellation_slug
144 + from satellites s left join countries c on c.code = s.country_code left join organizations o on o.id = s.operator_id left join constellations k on k.id = s.constellation_id
145 + where {' and '.join(where)} order by s.decay_date desc, s.norad_id desc limit :l offset :o""", **params, l=page.page_size, o=page.offset)
146 + summary = await fetch_one(conn, """select count(*) filter (where decay_date >= current_date - 7) as last_7d, count(*) filter (where decay_date >= current_date - 30) as last_30d,
147 + count(*) filter (where decay_date >= current_date - 365) as last_365d from satellites""")
148 + monthly = await fetch_all(conn, """select date_trunc('month', decay_date)::date as month, count(*) as decayed, count(*) filter (where object_type in ('PAYLOAD','STATION')) as payloads,
149 + count(*) filter (where object_type = 'DEBRIS') as debris, count(*) filter (where object_type = 'ROCKET_BODY') as rocket_bodies
150 + from satellites where decay_date >= current_date - interval '24 months' group by 1 order by 1""")
151 + upcoming = await fetch_all(conn, """select s.id, s.slug, s.canonical_name as name, s.norad_id, s.object_type, s.perigee_km, s.apogee_km, os.epoch, s.country_code, k.name as constellation_name
152 + from satellites s join orbital_state os on os.satellite_id = s.id left join constellations k on k.id = s.constellation_id
153 + where s.decay_date is null and os.perigee_km < 250 and os.eccentricity < 0.1 and os.epoch > now() - interval '7 days' order by os.perigee_km asc limit 25""")
154 + out = paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
155 + out["summary"] = summary
156 + out["monthly"] = monthly
157 + out["low_perigee_watch"] = upcoming
158 + out["disclaimer"] = "Decay dates are those published in the CelesTrak SATCAT. The low-perigee watch lists objects with a perigee under 250 km: it is NOT a reentry prediction and gives no time or location."
159 + return out
added src/satelliteindex/api/routers/misc.py +103 −0
@@ -0,0 +1,103 @@
1 +"""Sources, data status, methodology metrics, sitemap feeds, page-view beacon."""
2 +from __future__ import annotations
3 +
4 +from typing import Any
5 +
6 +from fastapi import APIRouter, Query, Request
7 +from pydantic import BaseModel, Field
8 +
9 +from satelliteindex.api.common import cached, envelope, freshness_status
10 +from satelliteindex.api.ratelimit import check
11 +from satelliteindex.db import connection, execute, fetch_all, fetch_one
12 +from satelliteindex.services.positions import positions
13 +
14 +router = APIRouter(prefix="/api/v1", tags=["meta"])
15 +
16 +
17 +@router.get("/sources")
18 +async def sources(request: Request) -> dict[str, Any]:
19 + async def produce() -> list[dict[str, Any]]:
20 + async with connection() as conn:
21 + rows = await fetch_all(conn, """
22 + select src.*, (select json_agg(json_build_object('name', c.name, 'description', c.description, 'interval_seconds', c.interval_seconds, 'enabled', c.enabled,
23 + 'last_success_at', c.last_success_at, 'last_attempt_at', c.last_attempt_at, 'last_duration_ms', c.last_duration_ms,
24 + 'consecutive_failures', c.consecutive_failures, 'circuit_open_until', c.circuit_open_until, 'next_run_at', c.next_run_at))
25 + from connectors c where c.source_id = src.id) as connectors,
26 + (select count(*) from field_provenance p where p.source_id = src.id) as provenance_rows,
27 + (select count(*) from raw_records r where r.source_id = src.id) as raw_snapshots,
28 + (select max(fetched_at) from raw_records r where r.source_id = src.id) as last_snapshot_at
29 + from sources src order by src.priority desc""")
30 + for r in rows:
31 + last = max([c["last_success_at"] for c in (r["connectors"] or []) if c["last_success_at"]] or [None], default=None)
32 + from datetime import datetime
33 + dt = datetime.fromisoformat(last) if isinstance(last, str) else last
34 + iv = min([c["interval_seconds"] for c in (r["connectors"] or [])] or [86400])
35 + r["freshness"] = freshness_status(dt, aging_s=iv * 3, stale_s=iv * 12) if r["enabled"] else "not_enabled"
36 + return rows
37 +
38 + return envelope(await cached("sources", 120, produce), request)
39 +
40 +
41 +@router.get("/sources/status")
42 +async def sources_status(request: Request) -> dict[str, Any]:
43 + async with connection() as conn:
44 + rows = await fetch_all(conn, """select c.name, c.source_id, s.name as source_name, c.enabled, c.interval_seconds, c.last_success_at, c.last_attempt_at, c.last_duration_ms,
45 + c.consecutive_failures, c.circuit_open_until, c.next_run_at,
46 + (select status from connector_runs r where r.connector_name = c.name order by started_at desc limit 1) as last_status,
47 + (select error from connector_runs r where r.connector_name = c.name and status = 'failed' order by started_at desc limit 1) as last_error
48 + from connectors c join sources s on s.id = c.source_id order by c.priority desc""")
49 + latest_epoch = await fetch_one(conn, "select max(epoch) as max_epoch, percentile_cont(0.5) within group (order by extract(epoch from now() - epoch)/3600) as median_age_h from orbital_state")
50 + for r in rows:
51 + r["freshness"] = freshness_status(r["last_success_at"], aging_s=r["interval_seconds"] * 3, stale_s=r["interval_seconds"] * 12) if r["enabled"] else "not_enabled"
52 + if r["last_error"]:
53 + r["last_error"] = r["last_error"][:300]
54 + return envelope({"connectors": rows, "orbit": {"latest_epoch": latest_epoch["max_epoch"], "median_element_age_hours": round(latest_epoch["median_age_h"], 1) if latest_epoch["median_age_h"] is not None else None,
55 + "propagator_objects": positions.count}}, request)
56 +
57 +
58 +@router.get("/methodology")
59 +async def methodology(request: Request) -> dict[str, Any]:
60 + async with connection() as conn:
61 + metrics = await fetch_all(conn, "select key, name, version, methodology, inputs, updated_at from metric_definitions order by key")
62 + consts = await fetch_all(conn, "select slug, name, match_patterns, celestrak_groups, service_type from constellations order by name")
63 + owners = await fetch_all(conn, "select code, name, kind, country_code from owner_codes order by code")
64 + return envelope({"metrics": metrics, "constellation_rules": consts, "owner_codes": owners}, request)
65 +
66 +
67 +@router.get("/sitemap/satellites")
68 +async def sitemap_satellites(request: Request, page: int = Query(1, ge=1), page_size: int = Query(5000, ge=100, le=10000), on_orbit: bool = True) -> dict[str, Any]:
69 + async with connection() as conn:
70 + where = "decay_date is null" if on_orbit else "true"
71 + rows = await fetch_all(conn, f"select slug, updated_at, status from satellites where {where} order by norad_id limit :l offset :o", l=page_size, o=(page - 1) * page_size)
72 + total = await fetch_one(conn, f"select count(*) as n from satellites where {where}")
73 + return envelope({"items": rows, "total": total["n"], "page": page, "page_size": page_size}, request)
74 +
75 +
76 +@router.get("/sitemap/entities")
77 +async def sitemap_entities(request: Request) -> dict[str, Any]:
78 + async with connection() as conn:
79 + consts = await fetch_all(conn, "select k.slug, k.updated_at from constellations k join constellation_stats ks on ks.id = k.id where ks.total > 0")
80 + ops = await fetch_all(conn, "select o.slug, o.updated_at from organizations o join operator_stats os on os.id = o.id where os.total_payloads > 0")
81 + countries = await fetch_all(conn, "select c.slug from countries c join country_stats cs on cs.code = c.code where cs.total_objects > 0")
82 + launches = await fetch_all(conn, "select cospar_launch_id as slug, updated_at from launches where launch_date >= current_date - 1100 order by launch_date desc")
83 + sites = await fetch_all(conn, "select slug from launch_sites")
84 + return envelope({"constellations": consts, "operators": ops, "countries": countries, "launches": launches, "launch_sites": sites}, request)
85 +
86 +
87 +class ViewBeacon(BaseModel):
88 + entity_type: str = Field(pattern="^(satellite|constellation|operator|country|launch)$")
89 + entity_id: str = Field(min_length=3, max_length=64)
90 +
91 +
92 +@router.post("/views", status_code=204)
93 +async def record_view(beacon: ViewBeacon, request: Request) -> None:
94 + """Privacy-respecting popularity counter (no IP, no UA stored). Rate-limited per IP to blunt bots."""
95 + check(request, "default")
96 + async with connection() as conn:
97 + exists = await fetch_one(conn, f"select 1 from {'satellites' if beacon.entity_type == 'satellite' else 'constellations' if beacon.entity_type == 'constellation' else 'organizations' if beacon.entity_type == 'operator' else 'launches' if beacon.entity_type == 'launch' else 'countries'} where {'code' if beacon.entity_type == 'country' else 'id'} = :i", i=beacon.entity_id)
98 + if not exists:
99 + return None
100 + await execute(conn, """insert into page_views (day, entity_type, entity_id, views) values (current_date, :t, :i, 1)
101 + on conflict (day, entity_type, entity_id) do update set views = page_views.views + 1""", t=beacon.entity_type, i=beacon.entity_id)
102 + await conn.commit()
103 + return None
added src/satelliteindex/api/routers/operators.py +61 −0
@@ -0,0 +1,61 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from fastapi import APIRouter, Depends, Request
6 +
7 +from satelliteindex.api.common import ApiProblem, Page, cached, envelope, paginated
8 +from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
9 +
10 +router = APIRouter(prefix="/api/v1", tags=["operators"])
11 +
12 +OP_COLS = """o.id, o.slug, o.name, o.kind, o.official_url, o.description, o.country_code, c.name as country_name, c.slug as country_slug,
13 + os.active_payloads, os.on_orbit_payloads, os.total_payloads, os.decayed, os.constellations, os.launches, os.payloads_last_365d, os.first_launch, os.last_launch"""
14 +OP_FROM = "from organizations o left join operator_stats os on os.id = o.id left join countries c on c.code = o.country_code"
15 +
16 +
17 +@router.get("/operators")
18 +async def list_operators(request: Request, page: Page = Depends(), kind: str | None = None, country: str | None = None, sort: str = "active", q: str | None = None) -> dict[str, Any]:
19 + where, params = ["coalesce(os.total_payloads,0) > 0"], {}
20 + if kind:
21 + where.append("o.kind = :kind"); params["kind"] = kind
22 + if country:
23 + where.append("(o.country_code = :country or c.slug = :country)"); params["country"] = country.upper() if len(country) == 2 else country
24 + if q:
25 + where.append("o.name ilike :q"); params["q"] = f"%{q}%"
26 + order = {"active": "os.active_payloads desc nulls last", "total": "os.total_payloads desc", "growth": "os.payloads_last_365d desc", "name": "o.name"}.get(sort, "os.active_payloads desc nulls last")
27 + async with connection() as conn:
28 + total = await fetch_val(conn, f"select count(*) {OP_FROM} where {' and '.join(where)}", **params)
29 + rows = await fetch_all(conn, f"select {OP_COLS} {OP_FROM} where {' and '.join(where)} order by {order}, o.name limit :l offset :o", **params, l=page.page_size, o=page.offset)
30 + return paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
31 +
32 +
33 +@router.get("/operators/{slug}")
34 +async def operator_detail(slug: str, request: Request) -> dict[str, Any]:
35 + async def produce() -> dict[str, Any]:
36 + async with connection() as conn:
37 + o = await fetch_one(conn, f"select {OP_COLS} {OP_FROM} where o.slug = :s", s=slug)
38 + if o is None:
39 + raise ApiProblem(404, "Operator not found", slug)
40 + oid = o["id"]
41 + aliases = await fetch_all(conn, "select alias from organization_aliases where organization_id = :o order by alias", o=oid)
42 + constellations = await fetch_all(conn, """select k.id, k.slug, k.name, k.service_type, k.orbit_class, ks.active, ks.total, ks.launched_last_365d
43 + from constellations k left join constellation_stats ks on ks.id = k.id where k.operator_id = :o order by ks.active desc nulls last""", o=oid)
44 + status_dist = await fetch_all(conn, "select status, count(*) as count from satellites where operator_id = :o group by 1 order by 2 desc", o=oid)
45 + orbit_dist = await fetch_all(conn, "select coalesce(orbit_class,'UNKNOWN') as orbit_class, count(*) as count from satellites where operator_id = :o and decay_date is null group by 1 order by 2 desc", o=oid)
46 + mission_dist = await fetch_all(conn, "select coalesce(mission_type,'unknown') as mission_type, count(*) as count from satellites where operator_id = :o and status = 'ACTIVE' group by 1 order by 2 desc", o=oid)
47 + growth = await fetch_all(conn, """select extract(year from launch_date)::int as year, count(*) as launched, count(*) filter (where status = 'ACTIVE') as still_active
48 + from satellites where operator_id = :o and launch_date is not null group by 1 order by 1""", o=oid)
49 + launches = await fetch_all(conn, """select l.id, l.cospar_launch_id, l.launch_date, ls.name as site_name, ls.slug as site_slug, count(s.id) as satellites
50 + from satellites s join launches l on l.id = s.launch_id left join launch_sites ls on ls.code = l.launch_site_code
51 + where s.operator_id = :o group by l.id, l.cospar_launch_id, l.launch_date, ls.name, ls.slug order by l.launch_date desc nulls last limit 25""", o=oid)
52 + fleet = await fetch_all(conn, """select id, slug, canonical_name as name, norad_id, status, orbit_class, launch_date, perigee_km, mission_type from satellites
53 + where operator_id = :o and status = 'ACTIVE' order by launch_date desc nulls last limit 30""", o=oid)
54 + events = await fetch_all(conn, """select e.id, e.type, e.title, e.summary, e.event_time, e.confidence from events e join event_entities x on x.event_id = e.id
55 + join satellites s on s.id = x.entity_id and x.entity_type = 'satellite' where s.operator_id = :o order by e.event_time desc limit 20""", o=oid)
56 + sites = await fetch_all(conn, """select ls.code, ls.name, ls.slug, ls.country_code, count(distinct s.launch_id) as launches from satellites s join launch_sites ls on ls.code = s.launch_site_code
57 + where s.operator_id = :o group by 1,2,3,4 order by 5 desc limit 10""", o=oid)
58 + return {**o, "aliases": [a["alias"] for a in aliases], "constellations_list": constellations, "status_distribution": status_dist, "orbit_distribution": orbit_dist,
59 + "mission_distribution": mission_dist, "growth": growth, "launches_list": launches, "fleet_sample": fleet, "events": events, "launch_sites": sites}
60 +
61 + return envelope(await cached(f"operator:{slug}", 600, produce), request)
added src/satelliteindex/api/routers/orbit.py +22 −0
@@ -0,0 +1,22 @@
1 +"""Batch positions for the globe."""
2 +from __future__ import annotations
3 +
4 +from datetime import UTC, datetime
5 +from typing import Any
6 +
7 +from fastapi import APIRouter, Depends, Query, Request
8 +
9 +from satelliteindex.api.common import json_response
10 +from satelliteindex.api.ratelimit import limiter
11 +from satelliteindex.services.positions import positions
12 +
13 +router = APIRouter(prefix="/api/v1", tags=["orbit"])
14 +
15 +
16 +@router.get("/orbit/positions", dependencies=[Depends(limiter("positions"))])
17 +async def batch_positions(request: Request, step: int = Query(30, ge=10, le=120), t: str | None = None) -> Any:
18 + """Compact parallel arrays: pos = [lat0, lon0, alt0, lat1, lon1, alt1] × count, for client-side interpolation between t0 and t1."""
19 + when = datetime.fromisoformat(t.replace("Z", "+00:00")).astimezone(UTC) if t else None
20 + snap = await positions.snapshot(when, step_s=step)
21 + return json_response({"data": snap, "meta": {"request_id": request.state.request_id, "generated_at": datetime.now(UTC).isoformat(),
22 + "disclaimer": "SGP4 from public element sets; positional accuracy typically 1–10 km, worse with epoch age."}}, cache_s=20)
added src/satelliteindex/api/routers/satellites.py +251 −0
@@ -0,0 +1,251 @@
1 +"""Satellites: list/filter, detail (by slug, NORAD or internal id), orbit history, live position, ground track, history, sources."""
2 +from __future__ import annotations
3 +
4 +from datetime import UTC, datetime
5 +from typing import Any
6 +
7 +from fastapi import APIRouter, Depends, Query, Request
8 +
9 +from satelliteindex.api.common import ApiProblem, Page, cached, envelope, freshness_status, paginated
10 +from satelliteindex.api.ratelimit import limiter
11 +from satelliteindex.db import connection, fetch_all, fetch_one, fetch_val
12 +from satelliteindex.orbital.propagate import Elements, ground_track, propagate_one
13 +from satelliteindex.services.positions import positions
14 +
15 +router = APIRouter(prefix="/api/v1", tags=["satellites"])
16 +
17 +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 + 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,
19 + s.country_code, c.name as country_name, c.slug as country_slug, s.owner_code, oc.name as owner_name,
20 + s.operator_id, o.name as operator_name, o.slug as operator_slug, s.constellation_id, k.name as constellation_name, k.slug as constellation_slug,
21 + s.launch_id, l.cospar_launch_id, ls.name as launch_site_name, ls.slug as launch_site_slug, s.first_seen_at, s.last_seen_at, s.updated_at"""
22 +SAT_FROM = """from satellites s
23 + left join countries c on c.code = s.country_code left join owner_codes oc on oc.code = s.owner_code
24 + left join organizations o on o.id = s.operator_id left join constellations k on k.id = s.constellation_id
25 + left join launches l on l.id = s.launch_id left join launch_sites ls on ls.code = s.launch_site_code"""
26 +
27 +SORTS = {
28 + "launch_date": "s.launch_date desc nulls last, s.norad_id desc", "-launch_date": "s.launch_date asc nulls last",
29 + "norad": "s.norad_id asc", "-norad": "s.norad_id desc", "name": "s.canonical_name asc", "-name": "s.canonical_name desc",
30 + "perigee": "s.perigee_km asc nulls last", "-perigee": "s.perigee_km desc nulls last", "apogee": "s.apogee_km asc nulls last", "-apogee": "s.apogee_km desc nulls last",
31 + "inclination": "s.inclination_deg asc nulls last", "-inclination": "s.inclination_deg desc nulls last", "period": "s.period_minutes asc nulls last",
32 + "decay_date": "s.decay_date desc nulls last", "epoch": "s.latest_epoch desc nulls last", "updated": "s.updated_at desc",
33 +}
34 +
35 +
36 +def _filters(q: dict[str, Any]) -> tuple[str, dict[str, Any]]:
37 + where, params = ["true"], {}
38 + if q.get("status"):
39 + where.append("s.status = any(:status)"); params["status"] = [x.upper() for x in q["status"].split(",")]
40 + if q.get("object_type"):
41 + where.append("s.object_type = any(:otype)"); params["otype"] = [x.upper() for x in q["object_type"].split(",")]
42 + if q.get("orbit_class"):
43 + where.append("s.orbit_class = any(:oclass)"); params["oclass"] = [x.upper() for x in q["orbit_class"].split(",")]
44 + if q.get("mission_type"):
45 + where.append("s.mission_type = any(:mtype)"); params["mtype"] = q["mission_type"].split(",")
46 + if q.get("country"):
47 + where.append("(s.country_code = :country or c.slug = :country)"); params["country"] = q["country"].upper() if len(q["country"]) == 2 else q["country"]
48 + if q.get("operator"):
49 + where.append("o.slug = :operator"); params["operator"] = q["operator"]
50 + if q.get("constellation"):
51 + where.append("k.slug = :constellation"); params["constellation"] = q["constellation"]
52 + if q.get("launch"):
53 + where.append("l.cospar_launch_id = :launch"); params["launch"] = q["launch"]
54 + if q.get("launch_site"):
55 + where.append("(s.launch_site_code = :site or ls.slug = :site)"); params["site"] = q["launch_site"]
56 + if q.get("on_orbit") is not None:
57 + where.append("s.decay_date is null" if q["on_orbit"] else "s.decay_date is not null")
58 + if q.get("has_gp") is not None:
59 + where.append("s.has_gp = :has_gp"); params["has_gp"] = q["has_gp"]
60 + if q.get("launched_after"):
61 + where.append("s.launch_date >= :la"); params["la"] = q["launched_after"]
62 + if q.get("launched_before"):
63 + where.append("s.launch_date <= :lb"); params["lb"] = q["launched_before"]
64 + if q.get("decayed_after"):
65 + where.append("s.decay_date >= :da"); params["da"] = q["decayed_after"]
66 + if q.get("min_perigee") is not None:
67 + where.append("s.perigee_km >= :minp"); params["minp"] = q["min_perigee"]
68 + if q.get("max_perigee") is not None:
69 + where.append("s.perigee_km <= :maxp"); params["maxp"] = q["max_perigee"]
70 + if q.get("tag"):
71 + where.append("exists (select 1 from satellite_tags t where t.satellite_id = s.id and t.tag = :tag)"); params["tag"] = q["tag"]
72 + if q.get("q"):
73 + where.append("(s.canonical_name ilike :q or s.norad_id::text = :qexact or s.cospar_id ilike :q)"); params["q"] = f"%{q['q']}%"; params["qexact"] = q["q"]
74 + return " and ".join(where), params
75 +
76 +
77 +@router.get("/satellites")
78 +async def list_satellites(
79 + request: Request, page: Page = Depends(),
80 + status: str | None = None, object_type: str | None = None, orbit_class: str | None = None, mission_type: str | None = None,
81 + country: str | None = None, operator: str | None = None, constellation: str | None = None, launch: str | None = None, launch_site: str | None = None,
82 + on_orbit: bool | None = None, has_gp: bool | None = None, launched_after: str | None = None, launched_before: str | None = None, decayed_after: str | None = None,
83 + min_perigee: float | None = None, max_perigee: float | None = None, tag: str | None = None, q: str | None = None,
84 + sort: str = Query("launch_date", pattern="^-?[a-z_]+$"),
85 +) -> dict[str, Any]:
86 + where, params = _filters(locals())
87 + order = SORTS.get(sort, SORTS["launch_date"])
88 + async with connection() as conn:
89 + total = await fetch_val(conn, f"select count(*) {SAT_FROM} where {where}", **params)
90 + rows = await fetch_all(conn, f"select {SAT_COLS} {SAT_FROM} where {where} order by {order} limit :lim offset :off", **params, lim=page.page_size, off=page.offset)
91 + return paginated(rows, page=page.page, page_size=page.page_size, total=int(total), request=request)
92 +
93 +
94 +@router.get("/satellites/facets")
95 +async def satellite_facets(request: Request, status: str | None = None, object_type: str | None = None, orbit_class: str | None = None, mission_type: str | None = None,
96 + country: str | None = None, operator: str | None = None, constellation: str | None = None, on_orbit: bool | None = None, q: str | None = None) -> dict[str, Any]:
97 + where, params = _filters(locals())
98 +
99 + async def produce() -> dict[str, Any]:
100 + async with connection() as conn:
101 + out = {}
102 + for name, col in (("status", "s.status"), ("object_type", "s.object_type"), ("orbit_class", "s.orbit_class"), ("mission_type", "s.mission_type")):
103 + out[name] = await fetch_all(conn, f"select {col} as value, count(*) as count {SAT_FROM} where {where} and {col} is not null group by 1 order by 2 desc limit 20", **params)
104 + out["country"] = await fetch_all(conn, f"select c.code as value, c.name as label, c.slug, count(*) as count {SAT_FROM} where {where} and c.code is not null group by 1,2,3 order by 4 desc limit 30", **params)
105 + out["constellation"] = await fetch_all(conn, f"select k.slug as value, k.name as label, count(*) as count {SAT_FROM} where {where} and k.id is not null group by 1,2 order by 3 desc limit 30", **params)
106 + return out
107 +
108 + key = "facets:" + ":".join(f"{k}={v}" for k, v in sorted(params.items()))
109 + return envelope(await cached(key, 600, produce), request)
110 +
111 +
112 +async def _load(conn, ident: str) -> dict[str, Any] | None: # type: ignore[no-untyped-def]
113 + if ident.isdigit():
114 + row = await fetch_one(conn, f"select {SAT_COLS} {SAT_FROM} where s.norad_id = :n", n=int(ident))
115 + if row:
116 + return row
117 + row = await fetch_one(conn, f"select {SAT_COLS} {SAT_FROM} where s.slug = :s or s.id = :s", s=ident)
118 + if row:
119 + return row
120 + # slug alias → redirect target
121 + alias = await fetch_one(conn, "select satellite_id from satellite_slugs where slug = :s", s=ident)
122 + if alias:
123 + row = await fetch_one(conn, f"select {SAT_COLS} {SAT_FROM} where s.id = :i", i=alias["satellite_id"])
124 + if row:
125 + row["redirected_from"] = ident
126 + return row
127 + return None
128 +
129 +
130 +@router.get("/satellites/{ident}")
131 +async def satellite_detail(ident: str, request: Request) -> dict[str, Any]:
132 + async with connection() as conn:
133 + sat = await _load(conn, ident)
134 + if sat is None:
135 + raise ApiProblem(404, "Satellite not found", ident)
136 + sid = sat["id"]
137 + state = await fetch_one(conn, """select epoch, mean_motion, eccentricity, inclination, raan, arg_of_perigee, mean_anomaly, bstar, mean_motion_dot,
138 + semi_major_axis_km, perigee_km, apogee_km, period_minutes, orbit_class, source_id, updated_at from orbital_state where satellite_id = :s""", s=sid)
139 + aliases = await fetch_all(conn, "select alias, source_id from satellite_aliases where satellite_id = :s order by alias", s=sid)
140 + tags = await fetch_all(conn, "select tag, source_id, last_seen_at from satellite_tags where satellite_id = :s order by tag", s=sid)
141 + identifiers = await fetch_all(conn, "select identifier_type, identifier_value, source_id, verified from entity_identifiers where entity_type = 'satellite' and entity_id = :s", s=sid)
142 + history = await fetch_all(conn, "select field, old_value, new_value, source_id, changed_at from satellite_status_history where satellite_id = :s order by changed_at desc limit 50", s=sid)
143 + memberships = await fetch_all(conn, """select m.constellation_id, k.slug, k.name, m.method, m.since, m.until from constellation_memberships m join constellations k on k.id = m.constellation_id
144 + where m.satellite_id = :s order by m.since desc""", s=sid)
145 + prov = await fetch_all(conn, """select p.field_name, p.source_id, src.name as source_name, p.observed_at, p.confidence from field_provenance p join sources src on src.id = p.source_id
146 + where p.entity_type = 'satellite' and p.entity_id = :s order by p.field_name""", s=sid)
147 + events = await fetch_all(conn, """select e.id, e.type, e.title, e.summary, e.event_time, e.confidence, e.source_id from events e join event_entities x on x.event_id = e.id
148 + where x.entity_type = 'satellite' and x.entity_id = :s order by e.event_time desc limit 30""", s=sid)
149 + flags = await fetch_all(conn, "select flag, detail, created_at from data_quality_flags where entity_type = 'satellite' and entity_id = :s and resolved_at is null", s=sid)
150 + siblings = await fetch_all(conn, f"select s.id, s.slug, s.canonical_name as name, s.norad_id, s.object_type, s.status {SAT_FROM} where s.launch_id = :l and s.id <> :s order by s.object_type, s.norad_id limit 40",
151 + l=sat["launch_id"], s=sid) if sat["launch_id"] else []
152 + 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 + 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 + 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
156 + 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)
158 + live = None
159 + if state:
160 + el = Elements(satellite_id=sid, norad_id=sat["norad_id"], epoch=state["epoch"], mean_motion=state["mean_motion"], eccentricity=state["eccentricity"],
161 + inclination=state["inclination"], raan=state["raan"], arg_of_perigee=state["arg_of_perigee"], mean_anomaly=state["mean_anomaly"],
162 + bstar=state["bstar"], mean_motion_dot=state["mean_motion_dot"])
163 + live = propagate_one(el, datetime.now(UTC))
164 + live.pop("position_teme_km", None); live.pop("velocity_teme_km_s", None)
165 + live["timestamp"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
166 + live["source_epoch"] = state["epoch"].isoformat()
167 + live["epoch_age_hours"] = round((datetime.now(UTC) - state["epoch"]).total_seconds() / 3600, 1)
168 + freshness = {
169 + "orbit": {"updated_at": state["updated_at"].isoformat() if state else None, "epoch": state["epoch"].isoformat() if state else None,
170 + "status": freshness_status(state["epoch"] if state else None)},
171 + "metadata": {"updated_at": sat["updated_at"].isoformat() if sat["updated_at"] else None, "status": freshness_status(sat["last_seen_at"], aging_s=36 * 3600, stale_s=96 * 3600)},
172 + }
173 + return envelope({**sat, "orbital_state": state, "live": live, "aliases": aliases, "tags": tags, "identifiers": identifiers, "history": history,
174 + "constellation_memberships": memberships, "provenance": prov, "events": events, "quality_flags": flags, "launch_siblings": siblings,
175 + "related": related, "sources": sources, "freshness": freshness}, request)
176 +
177 +
178 +@router.get("/satellites/{ident}/orbit")
179 +async def satellite_orbit(ident: str, request: Request, limit: int = Query(200, ge=1, le=2000)) -> dict[str, Any]:
180 + async with connection() as conn:
181 + sat = await _load(conn, ident)
182 + if sat is None:
183 + raise ApiProblem(404, "Satellite not found", ident)
184 + rows = await fetch_all(conn, """select epoch, mean_motion, eccentricity, inclination, raan, arg_of_perigee, mean_anomaly, bstar, semi_major_axis_km, perigee_km, apogee_km,
185 + period_minutes, source_id, received_at from orbital_elements where satellite_id = :s order by epoch desc limit :l""", s=sat["id"], l=limit)
186 + return envelope({"satellite": {"id": sat["id"], "slug": sat["slug"], "name": sat["name"], "norad_id": sat["norad_id"]}, "elements": rows, "count": len(rows)}, request)
187 +
188 +
189 +@router.get("/satellites/{ident}/position", dependencies=[Depends(limiter("position"))])
190 +async def satellite_position(ident: str, request: Request, time: str | None = Query(None, alias="time")) -> dict[str, Any]:
191 + async with connection() as conn:
192 + sat = await _load(conn, ident)
193 + if sat is None:
194 + raise ApiProblem(404, "Satellite not found", ident)
195 + state = await fetch_one(conn, "select * from orbital_state where satellite_id = :s", s=sat["id"])
196 + if not state:
197 + raise ApiProblem(404, "No orbital elements", "this object has no element set (position unavailable)")
198 + t = datetime.fromisoformat(time.replace("Z", "+00:00")).astimezone(UTC) if time else datetime.now(UTC)
199 + el = Elements(satellite_id=sat["id"], norad_id=sat["norad_id"], epoch=state["epoch"], mean_motion=state["mean_motion"], eccentricity=state["eccentricity"],
200 + inclination=state["inclination"], raan=state["raan"], arg_of_perigee=state["arg_of_perigee"], mean_anomaly=state["mean_anomaly"],
201 + bstar=state["bstar"], mean_motion_dot=state["mean_motion_dot"], mean_motion_ddot=state["mean_motion_ddot"])
202 + p = propagate_one(el, t)
203 + if p.get("error"):
204 + raise ApiProblem(422, "Propagation error", p["error"])
205 + return envelope({"lat": round(p["lat"], 5), "lon": round(p["lon"], 5), "altitude_km": round(p["altitude_km"], 2), "velocity_km_s": round(p["velocity_km_s"], 4),
206 + "timestamp": t.isoformat().replace("+00:00", "Z"), "source_epoch": state["epoch"].isoformat(), "orbit_class": state["orbit_class"],
207 + "satellite": {"id": sat["id"], "slug": sat["slug"], "name": sat["name"], "norad_id": sat["norad_id"]}}, request,
208 + disclaimer="SGP4 propagation from public element sets; accuracy degrades with epoch age. Not for operational use.")
209 +
210 +
211 +@router.get("/satellites/{ident}/track", dependencies=[Depends(limiter("position"))])
212 +async def satellite_track(ident: str, request: Request, before: int = Query(45, ge=0, le=360), after: int = Query(90, ge=1, le=720), step: int = Query(60, ge=10, le=600)) -> dict[str, Any]:
213 + async with connection() as conn:
214 + sat = await _load(conn, ident)
215 + if sat is None:
216 + raise ApiProblem(404, "Satellite not found", ident)
217 + state = await fetch_one(conn, "select * from orbital_state where satellite_id = :s", s=sat["id"])
218 + if not state:
219 + raise ApiProblem(404, "No orbital elements", "ground track unavailable")
220 + el = Elements(satellite_id=sat["id"], norad_id=sat["norad_id"], epoch=state["epoch"], mean_motion=state["mean_motion"], eccentricity=state["eccentricity"],
221 + inclination=state["inclination"], raan=state["raan"], arg_of_perigee=state["arg_of_perigee"], mean_anomaly=state["mean_anomaly"],
222 + bstar=state["bstar"], mean_motion_dot=state["mean_motion_dot"], mean_motion_ddot=state["mean_motion_ddot"])
223 + t0 = datetime.now(UTC)
224 + pts = ground_track(el, t0, minutes_before=before, minutes_after=after, step_s=step)
225 + return envelope({"t0": t0.isoformat().replace("+00:00", "Z"), "points": pts, "source_epoch": state["epoch"].isoformat()}, request)
226 +
227 +
228 +@router.get("/satellites/{ident}/history")
229 +async def satellite_history(ident: str, request: Request) -> dict[str, Any]:
230 + async with connection() as conn:
231 + sat = await _load(conn, ident)
232 + if sat is None:
233 + raise ApiProblem(404, "Satellite not found", ident)
234 + hist = await fetch_all(conn, "select field, old_value, new_value, source_id, changed_at from satellite_status_history where satellite_id = :s order by changed_at desc limit 200", s=sat["id"])
235 + alt = await fetch_all(conn, """select date_trunc('day', epoch) as day, avg(perigee_km) as perigee_km, avg(apogee_km) as apogee_km, avg(period_minutes) as period_minutes,
236 + avg(inclination) as inclination from orbital_elements where satellite_id = :s group by 1 order by 1""", s=sat["id"])
237 + return envelope({"changes": hist, "altitude_series": alt}, request)
238 +
239 +
240 +@router.get("/satellites/{ident}/live")
241 +async def satellite_live(ident: str, request: Request) -> dict[str, Any]:
242 + """Position from the shared in-memory propagator (cheap; used by the globe focus mode)."""
243 + async with connection() as conn:
244 + sat = await _load(conn, ident)
245 + if sat is None:
246 + raise ApiProblem(404, "Satellite not found", ident)
247 + p = await positions.one(sat["id"])
248 + if p is None:
249 + raise ApiProblem(404, "No orbital elements", "not in the live propagator set")
250 + p.pop("position_teme_km", None); p.pop("velocity_teme_km_s", None)
251 + return envelope(p, request)
added src/satelliteindex/api/routers/search.py +100 −0
@@ -0,0 +1,100 @@
1 +"""Global search across satellites, operators, constellations, countries, launches and launch sites."""
2 +from __future__ import annotations
3 +
4 +import re
5 +from typing import Any
6 +
7 +from fastapi import APIRouter, Depends, Query, Request
8 +
9 +from satelliteindex.api.common import cached, envelope
10 +from satelliteindex.api.ratelimit import limiter
11 +from satelliteindex.db import connection, fetch_all
12 +
13 +router = APIRouter(prefix="/api/v1", tags=["search"])
14 +
15 +COSPAR_RE = re.compile(r"^(19|20)\d{2}-?\d{3}[A-Z]{0,3}$", re.I)
16 +KEYWORD_SHORTCUTS = {
17 + "leo": ("orbit_class", "LEO"), "meo": ("orbit_class", "MEO"), "geo": ("orbit_class", "GEO"), "heo": ("orbit_class", "HEO"),
18 + "debris": ("object_type", "DEBRIS"), "rocket body": ("object_type", "ROCKET_BODY"), "rocket bodies": ("object_type", "ROCKET_BODY"),
19 + "weather satellites": ("mission_type", "weather"), "weather": ("mission_type", "weather"), "navigation": ("mission_type", "navigation"),
20 + "earth observation": ("mission_type", "earth-observation"), "communications": ("mission_type", "communications"), "military": ("mission_type", "military"),
21 + "science": ("mission_type", "science"), "stations": ("object_type", "STATION"), "space station": ("object_type", "STATION"),
22 +}
23 +
24 +
25 +@router.get("/search", dependencies=[Depends(limiter("search"))])
26 +async def search(request: Request, q: str = Query(..., min_length=1, max_length=120), limit: int = Query(20, ge=1, le=50), types: str | None = None) -> dict[str, Any]:
27 + term = q.strip()
28 + type_filter = [t for t in (types or "").split(",") if t] or None
29 +
30 + async def produce() -> dict[str, Any]:
31 + async with connection() as conn:
32 + results: list[dict[str, Any]] = []
33 + shortcuts: list[dict[str, Any]] = []
34 + low = term.lower()
35 + if low in KEYWORD_SHORTCUTS:
36 + f, v = KEYWORD_SHORTCUTS[low]
37 + shortcuts.append({"label": f"All {v.replace('_', ' ').lower()} objects", "href": f"/satellites?{f}={v}", "filter": {f: v}})
38 + # exact identifier hits first
39 + if term.isdigit():
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
42 + from satellites where norad_id = :n""", n=int(term))
43 + results += rows
44 + if COSPAR_RE.match(term):
45 + norm = term.upper()
46 + if "-" not in norm:
47 + norm = norm[:4] + "-" + norm[4:]
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
50 + from satellites where cospar_id ilike :c order by cospar_id limit 20""", c=norm + "%")
51 + results += rows
52 + if len(norm) == 8:
53 + results += await fetch_all(conn, """select 'launch' as entity_type, id as entity_id, cospar_launch_id as slug,
54 + coalesce(primary_name, 'Launch') || ' (' || cospar_launch_id || ')' as title,
55 + concat_ws(' · ', launch_date::text, payload_count || ' payloads') as subtitle, 96.0 as score
56 + from launches where cospar_launch_id = :c""", c=norm)
57 + tsq = " & ".join(f"{w}:*" for w in re.findall(r"[A-Za-z0-9]+", term)[:6]) or term
58 + type_sql = " and entity_type = any(:types)" if type_filter else ""
59 + rows = await fetch_all(conn, f"""
60 + select entity_type, entity_id, slug, title, subtitle,
61 + (case when lower(title) = lower(:t) then 50 else 0 end
62 + + case when title ilike :prefix then 20 else 0 end
63 + + coalesce(ts_rank_cd(tsv, to_tsquery('simple', :tsq)), 0) * 10
64 + + similarity(title, :t) * 15 + weight) as score
65 + from search_index
66 + where (tsv @@ to_tsquery('simple', :tsq) or title ilike :like or keywords ilike :like or title % :t) {type_sql}
67 + order by score desc, title limit :lim""", t=term, prefix=term + "%", like="%" + term + "%", tsq=tsq, lim=limit, types=type_filter)
68 + seen = {(r["entity_type"], r["entity_id"]) for r in results}
69 + for r in rows:
70 + if (r["entity_type"], r["entity_id"]) not in seen:
71 + results.append(r)
72 + seen.add((r["entity_type"], r["entity_id"]))
73 + for r in results:
74 + r["score"] = round(float(r["score"]), 2)
75 + r["href"] = _href(r["entity_type"], r["slug"])
76 + return {"query": term, "results": results[:limit], "shortcuts": shortcuts}
77 +
78 + key = f"search:{term.lower()}:{limit}:{types or ''}"
79 + return envelope(await cached(key, 120, produce), request)
80 +
81 +
82 +def _href(entity_type: str, slug: str) -> str:
83 + return {"satellite": f"/satellite/{slug}", "operator": f"/operator/{slug}", "constellation": f"/constellation/{slug}", "country": f"/country/{slug}",
84 + "launch": f"/launch/{slug}", "launch_site": f"/launch-sites/{slug}"}.get(entity_type, "/")
85 +
86 +
87 +@router.get("/search/suggest", dependencies=[Depends(limiter("search"))])
88 +async def suggest(request: Request, q: str = Query(..., min_length=1, max_length=60)) -> dict[str, Any]:
89 + term = q.strip()
90 +
91 + async def produce() -> list[dict[str, Any]]:
92 + async with connection() as conn:
93 + rows = await fetch_all(conn, """select entity_type, slug, title, subtitle from search_index
94 + where title ilike :p or keywords ilike :p2 order by (title ilike :p) desc, weight desc, title limit 8""",
95 + p=term + "%", p2="%" + term + "%")
96 + for r in rows:
97 + r["href"] = _href(r["entity_type"], r["slug"])
98 + return rows
99 +
100 + return envelope(await cached(f"suggest:{term.lower()}", 300, produce), request)
added src/satelliteindex/api/routers/stats.py +125 −0
@@ -0,0 +1,125 @@
1 +"""Global statistics, rankings, orbital density, trending."""
2 +from __future__ import annotations
3 +
4 +from typing import Any
5 +
6 +from fastapi import APIRouter, Query, Request
7 +
8 +from satelliteindex.analytics.stats import load_snapshot
9 +from satelliteindex.api.common import ApiProblem, cached, envelope
10 +from satelliteindex.db import connection, fetch_all
11 +
12 +router = APIRouter(prefix="/api/v1", tags=["stats"])
13 +
14 +
15 +@router.get("/stats")
16 +async def stats(request: Request) -> dict[str, Any]:
17 + async def produce() -> dict[str, Any]:
18 + async with connection() as conn:
19 + snap = await load_snapshot(conn)
20 + if snap is None:
21 + raise ApiProblem(503, "Statistics not computed yet", "run derived_analytics")
22 + return snap
23 +
24 + snap = await cached("stats:global", 300, produce)
25 + return envelope(snap, request, computed_at=snap["computed_at"])
26 +
27 +
28 +@router.get("/stats/home")
29 +async def home(request: Request) -> dict[str, Any]:
30 + """Everything the homepage needs in one call (cached)."""
31 + async def produce() -> dict[str, Any]:
32 + async with connection() as conn:
33 + snap = await load_snapshot(conn)
34 + if snap is None:
35 + raise ApiProblem(503, "Statistics not computed yet")
36 + latest_launches = await fetch_all(conn, """select l.id, l.cospar_launch_id, l.launch_date, l.payload_count, l.object_count, l.primary_name, ls.name as site_name, ls.slug as site_slug,
37 + ls.country_code as site_country, l.owner_codes from launches l left join launch_sites ls on ls.code = l.launch_site_code
38 + where l.launch_date is not null order by l.launch_date desc, l.cospar_launch_id desc limit 8""")
39 + events = await fetch_all(conn, """select e.id, e.type, e.title, e.summary, e.event_time, e.confidence, e.source_id,
40 + (select json_agg(json_build_object('type', x.entity_type, 'id', x.entity_id, 'slug', coalesce(s.slug, l.cospar_launch_id), 'name', coalesce(s.canonical_name, l.primary_name)))
41 + from event_entities x left join satellites s on x.entity_type='satellite' and s.id = x.entity_id left join launches l on x.entity_type='launch' and l.id = x.entity_id
42 + where x.event_id = e.id) as entities
43 + from events e order by e.event_time desc limit 10""")
44 + reentries = await fetch_all(conn, """select s.id, s.slug, s.canonical_name as name, s.norad_id, s.object_type, s.decay_date, s.country_code, c.name as country_name
45 + from satellites s left join countries c on c.code = s.country_code where s.decay_date is not null order by s.decay_date desc, s.norad_id desc limit 8""")
46 + trending = await fetch_all(conn, """select s.id, s.slug, s.canonical_name as name, s.norad_id, s.status, s.orbit_class, s.perigee_km, k.name as constellation_name, o.name as operator_name,
47 + sum(v.views) as views from page_views v join satellites s on s.id = v.entity_id and v.entity_type = 'satellite'
48 + left join constellations k on k.id = s.constellation_id left join organizations o on o.id = s.operator_id
49 + where v.day >= current_date - 7 group by s.id, k.name, o.name order by views desc limit 8""")
50 + if len(trending) < 6:
51 + # cold start: most-recent launches + flagship objects (labelled as "featured" by the UI)
52 + extra = await fetch_all(conn, """select s.id, s.slug, s.canonical_name as name, s.norad_id, s.status, s.orbit_class, s.perigee_km, k.name as constellation_name, o.name as operator_name, 0 as views
53 + from satellites s left join constellations k on k.id = s.constellation_id left join organizations o on o.id = s.operator_id
54 + where s.norad_id in (25544, 48274, 20580, 43013, 39634, 41866, 49260, 44714) or (s.object_type = 'STATION' and s.status = 'ACTIVE')
55 + order by s.norad_id limit 8""")
56 + seen = {t["id"] for t in trending}
57 + trending += [e for e in extra if e["id"] not in seen]
58 + sources = await fetch_all(conn, """select src.id, src.name, src.official, src.attribution_text, c.name as connector, c.last_success_at, c.interval_seconds, c.enabled
59 + from sources src left join connectors c on c.source_id = src.id where src.enabled order by src.priority desc""")
60 + return {"stats": snap["global"], "launches": snap["launches"], "by_orbit_class": snap["by_orbit_class"], "top_constellations": snap["top_constellations"][:10],
61 + "top_countries": snap["top_countries"][:12], "top_operators": snap["top_operators"][:12], "orbital_buckets": snap["orbital_buckets"],
62 + "latest_launches": latest_launches, "events": events, "reentries": reentries, "trending": trending[:8], "sources": sources, "computed_at": snap["computed_at"]}
63 +
64 + return envelope(await cached("stats:home", 300, produce), request)
65 +
66 +
67 +@router.get("/rankings")
68 +async def rankings(request: Request, metric: str = Query("constellations"), limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]:
69 + async def produce() -> dict[str, Any]:
70 + async with connection() as conn:
71 + if metric == "constellations":
72 + rows = await fetch_all(conn, """select ks.*, o.name as operator_name, o.slug as operator_slug, c.name as country_name,
73 + least(100, round((100.0 * ks.launched_last_30d / greatest(ks.active,1) + 10.0 * ks.launched_last_365d / greatest(ks.active,1))::numeric, 1)) as activity_score
74 + from constellation_stats ks left join organizations o on o.id = ks.operator_id left join countries c on c.code = ks.country_code
75 + where ks.total > 0 order by ks.active desc, ks.total desc limit :l""", l=limit)
76 + elif metric == "operators":
77 + rows = await fetch_all(conn, """select os.*, c.name as country_name from operator_stats os left join countries c on c.code = os.country_code
78 + where os.active_payloads > 0 order by os.active_payloads desc limit :l""", l=limit)
79 + elif metric == "countries":
80 + rows = await fetch_all(conn, "select * from country_stats where total_objects > 0 order by active_payloads desc, on_orbit_payloads desc limit :l", l=limit)
81 + elif metric == "countries-debris":
82 + rows = await fetch_all(conn, "select * from country_stats where debris_on_orbit > 0 order by debris_on_orbit desc limit :l", l=limit)
83 + elif metric == "launches":
84 + rows = 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,
85 + count(l.id) filter (where l.launch_date >= current_date - 365) as launches_last_365d, sum(l.payload_count) as payloads, max(l.launch_date) as last_launch
86 + 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
87 + group by ls.code, ls.name, ls.slug, ls.country_code, c.name, ls.latitude, ls.longitude having count(l.id) > 0 order by launches desc limit :l""", l=limit)
88 + elif metric == "fastest-growing":
89 + rows = await fetch_all(conn, """select ks.*, o.name as operator_name, o.slug as operator_slug,
90 + round(100.0 * ks.launched_last_365d / greatest(ks.active - ks.launched_last_365d, 1), 1) as growth_pct
91 + from constellation_stats ks left join organizations o on o.id = ks.operator_id
92 + where ks.launched_last_365d >= 5 order by growth_pct desc, ks.launched_last_365d desc limit :l""", l=limit)
93 + elif metric == "congested-shells":
94 + rows = await fetch_all(conn, """select floor(perigee_km / 50) * 50 as shell_km, count(*) as objects,
95 + count(*) filter (where object_type in ('PAYLOAD','STATION') and status = 'ACTIVE') as active_payloads,
96 + count(*) filter (where object_type = 'DEBRIS') as debris, count(distinct constellation_id) as constellations
97 + from satellites where decay_date is null and orbit_class = 'LEO' and perigee_km is not null
98 + group by 1 order by objects desc limit :l""", l=limit)
99 + elif metric == "launch-years":
100 + rows = await fetch_all(conn, "select * from launch_year_stats order by year desc limit :l", l=limit)
101 + else:
102 + raise ApiProblem(404, "Unknown ranking", metric)
103 + return {"metric": metric, "rows": rows}
104 +
105 + return envelope(await cached(f"rankings:{metric}:{limit}", 600, produce), request)
106 +
107 +
108 +@router.get("/orbit/density")
109 +async def density(request: Request) -> dict[str, Any]:
110 + async def produce() -> dict[str, Any]:
111 + async with connection() as conn:
112 + buckets = await fetch_all(conn, "select * from orbital_bucket_stats")
113 + fine = await fetch_all(conn, """select floor(perigee_km / 25) * 25 as alt_km, count(*) as objects,
114 + count(*) filter (where object_type in ('PAYLOAD','STATION') and status='ACTIVE') as active_payloads,
115 + count(*) filter (where object_type = 'DEBRIS') as debris, count(*) filter (where object_type = 'ROCKET_BODY') as rocket_bodies
116 + from satellites where decay_date is null and orbit_center = 'EA' and perigee_km between 100 and 2000 group by 1 order by 1""")
117 + incl = await fetch_all(conn, """select floor(inclination_deg / 5) * 5 as incl_deg, count(*) as objects,
118 + count(*) filter (where status='ACTIVE' and object_type in ('PAYLOAD','STATION')) as active_payloads
119 + from satellites where decay_date is null and orbit_center = 'EA' and inclination_deg is not null group by 1 order by 1""")
120 + order = ["0-200", "200-300", "300-400", "400-500", "500-600", "600-800", "800-1000", "1000-2000", "MEO", "GEO", "HEO", "OTHER", "UNKNOWN"]
121 + buckets.sort(key=lambda b: order.index(b["bucket"]) if b["bucket"] in order else 99)
122 + return {"buckets": buckets, "leo_profile_25km": fine, "inclination_profile_5deg": incl,
123 + "methodology": "Objects on orbit by perigee altitude (SATCAT + GP). Informational density, not a collision-risk metric."}
124 +
125 + return envelope(await cached("orbit:density", 900, produce), request)
added src/satelliteindex/cli.py +204 −0
@@ -0,0 +1,204 @@
1 +"""`si` — SatelliteIndex operations CLI (typer)."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +import json
6 +import logging
7 +from pathlib import Path
8 +from typing import Annotated
9 +
10 +import typer
11 +from rich.console import Console
12 +from rich.table import Table
13 +
14 +from satelliteindex.config import settings
15 +from satelliteindex.logging import setup_logging
16 +
17 +app = typer.Typer(name="si", help="SatelliteIndex data platform.", no_args_is_help=True, add_completion=False)
18 +console = Console(stderr=True)
19 +out = Console()
20 +
21 +
22 +@app.callback()
23 +def _main(verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False) -> None:
24 + setup_logging(level=logging.DEBUG if verbose else logging.INFO, service="si-cli")
25 + settings.ensure_dirs()
26 +
27 +
28 +def _run(coro): # type: ignore[no-untyped-def]
29 + from satelliteindex.db import dispose
30 +
31 + async def wrapper(): # type: ignore[no-untyped-def]
32 + try:
33 + return await coro
34 + finally:
35 + await dispose()
36 +
37 + return asyncio.run(wrapper())
38 +
39 +
40 +@app.command()
41 +def migrate(revision: str = "head") -> None:
42 + """Apply Alembic migrations."""
43 + from alembic import command
44 + from alembic.config import Config
45 +
46 + cfg = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
47 + cfg.set_main_option("script_location", str(Path(__file__).resolve().parents[2] / "migrations"))
48 + command.upgrade(cfg, revision)
49 + out.print("[green]migrations applied[/]")
50 +
51 +
52 +@app.command()
53 +def seed() -> None:
54 + """Seed sources, connectors, reference tables, organizations and constellations (idempotent)."""
55 + from satelliteindex.db import transaction
56 + from satelliteindex.registry.seed import seed as _seed
57 +
58 + async def go(): # type: ignore[no-untyped-def]
59 + async with transaction() as conn:
60 + return await _seed(conn)
61 +
62 + counts = _run(go())
63 + out.print(json.dumps(counts))
64 +
65 +
66 +@app.command()
67 +def run(name: Annotated[str, typer.Argument(help="connector name, or 'all' (in priority order)")], force: bool = typer.Option(False, "--force", help="ignore enabled flag / circuit breaker"),
68 + local_file: Annotated[list[str] | None, typer.Option("--file", help="group=/path/to/payload.json — ingest a local snapshot instead of fetching (repeatable)")] = None) -> None:
69 + """Run a connector once."""
70 + from satelliteindex.connectors import build, registry
71 + from satelliteindex.db import connection, fetch_all
72 +
73 + async def go(): # type: ignore[no-untyped-def]
74 + names = [name]
75 + if name == "all":
76 + async with connection() as conn:
77 + rows = await fetch_all(conn, "select name from connectors where enabled order by priority desc")
78 + names = [r["name"] for r in rows if r["name"] in registry()]
79 + for n in names:
80 + c = await build(n)
81 + if local_file:
82 + c.config["local_files"] = dict(kv.split("=", 1) for kv in local_file)
83 + try:
84 + ctx = await c.run(force=force)
85 + s = ctx.stats
86 + out.print(f"[bold]{n}[/] {'unchanged' if s.unchanged else 'ok'} fetched={s.fetched} created={s.created} updated={s.updated} skipped={s.skipped}")
87 + except Exception as exc: # noqa: BLE001
88 + out.print(f"[red]{n} failed:[/] {exc}")
89 + if name != "all":
90 + raise typer.Exit(code=1)
91 +
92 + _run(go())
93 +
94 +
95 +@app.command()
96 +def status() -> None:
97 + """Connector health table."""
98 + from satelliteindex.db import connection, fetch_all
99 +
100 + async def go(): # type: ignore[no-untyped-def]
101 + async with connection() as conn:
102 + return await fetch_all(conn, """select c.name, c.enabled, c.last_success_at, c.last_attempt_at, c.last_duration_ms, c.consecutive_failures, c.circuit_open_until,
103 + (select status from connector_runs r where r.connector_name = c.name order by started_at desc limit 1) as last_status,
104 + (select records_fetched from connector_runs r where r.connector_name = c.name order by started_at desc limit 1) as fetched
105 + from connectors c order by c.priority desc""")
106 +
107 + rows = _run(go())
108 + t = Table(title="connectors")
109 + for col in ("name", "enabled", "last_status", "fetched", "last_success", "duration_ms", "failures", "circuit"):
110 + t.add_column(col)
111 + for r in rows:
112 + t.add_row(r["name"], str(r["enabled"]), str(r["last_status"]), str(r["fetched"]), str(r["last_success_at"])[:19], str(r["last_duration_ms"]),
113 + str(r["consecutive_failures"]), str(r["circuit_open_until"] or ""))
114 + out.print(t)
115 +
116 +
117 +@app.command()
118 +def stats() -> None:
119 + """Print the global stats snapshot headline."""
120 + from satelliteindex.analytics.stats import load_snapshot
121 + from satelliteindex.db import connection
122 +
123 + async def go(): # type: ignore[no-untyped-def]
124 + async with connection() as conn:
125 + return await load_snapshot(conn)
126 +
127 + snap = _run(go())
128 + if not snap:
129 + out.print("[yellow]no snapshot yet — run `si run derived_analytics`[/]")
130 + raise typer.Exit(code=1)
131 + out.print(json.dumps({"computed_at": snap["computed_at"], **snap["global"], "launches": snap["launches"]}, indent=1, default=str))
132 +
133 +
134 +@app.command()
135 +def position(norad: int, when: str | None = None) -> None:
136 + """Propagate one object (by NORAD id) to now (or an ISO timestamp)."""
137 + from datetime import UTC, datetime
138 +
139 + from satelliteindex.db import connection, fetch_one
140 + from satelliteindex.orbital.propagate import Elements, propagate_one
141 +
142 + async def go(): # type: ignore[no-untyped-def]
143 + async with connection() as conn:
144 + r = await fetch_one(conn, """select o.*, s.canonical_name from orbital_state o join satellites s on s.id = o.satellite_id where s.norad_id = :n""", n=norad)
145 + if not r:
146 + out.print("[red]no element set[/]")
147 + raise typer.Exit(code=1)
148 + el = Elements(satellite_id=r["satellite_id"], norad_id=norad, epoch=r["epoch"], mean_motion=r["mean_motion"], eccentricity=r["eccentricity"],
149 + inclination=r["inclination"], raan=r["raan"], arg_of_perigee=r["arg_of_perigee"], mean_anomaly=r["mean_anomaly"], bstar=r["bstar"],
150 + mean_motion_dot=r["mean_motion_dot"], mean_motion_ddot=r["mean_motion_ddot"])
151 + t = datetime.fromisoformat(when).astimezone(UTC) if when else datetime.now(UTC)
152 + p = propagate_one(el, t)
153 + out.print(json.dumps({"name": r["canonical_name"], "t": t.isoformat(), **{k: v for k, v in p.items() if not k.startswith("position_") and not k.startswith("velocity_teme")}}, indent=1))
154 +
155 + _run(go())
156 +
157 +
158 +@app.command()
159 +def schedule() -> None:
160 + """Run the scheduler process (APScheduler + Redis locks). Used by PM2 `satelliteindex-scheduler`."""
161 + from satelliteindex.worker.scheduler import main
162 +
163 + main()
164 +
165 +
166 +@app.command()
167 +def api(host: str | None = None, port: int | None = None, reload: bool = False) -> None:
168 + """Serve the FastAPI app with uvicorn."""
169 + import uvicorn
170 +
171 + uvicorn.run("satelliteindex.api.main:app", host=host or settings.api_host, port=port or settings.api_port, reload=reload, log_level="info", access_log=False)
172 +
173 +
174 +@app.command()
175 +def backup(out_dir: Path | None = None, keep_days: int = 21) -> None:
176 + """pg_dump (custom format, compressed) into SI_DATA_DIR/backups, pruning old files."""
177 + import shutil
178 + import subprocess
179 + import time
180 + from urllib.parse import urlparse
181 +
182 + target = out_dir or (settings.data_dir / "backups")
183 + target.mkdir(parents=True, exist_ok=True)
184 + u = urlparse(settings.sync_database_url)
185 + db = u.path.lstrip("/")
186 + fname = target / f"satelliteindex-{time.strftime('%Y%m%d-%H%M%S')}.dump"
187 + pg_dump = shutil.which("pg_dump") or "/opt/homebrew/opt/postgresql@17/bin/pg_dump"
188 + cmd = [pg_dump, "-Fc", "-Z", "6", "-f", str(fname), "-d", db]
189 + if u.hostname:
190 + cmd += ["-h", u.hostname]
191 + if u.port:
192 + cmd += ["-p", str(u.port)]
193 + if u.username:
194 + cmd += ["-U", u.username]
195 + env = {"PGPASSWORD": u.password} if u.password else {}
196 + import os
197 + subprocess.run(cmd, check=True, env={**os.environ, **env})
198 + cutoff = time.time() - keep_days * 86400
199 + removed = 0
200 + for f in target.glob("satelliteindex-*.dump"):
201 + if f.stat().st_mtime < cutoff:
202 + f.unlink()
203 + removed += 1
204 + out.print(f"[green]backup written[/] {fname} ({fname.stat().st_size // 1024} KiB), pruned {removed}")
added src/satelliteindex/config.py +67 −0
@@ -0,0 +1,67 @@
1 +"""Runtime settings (environment variables, `SI_` prefix for platform knobs). Never log `settings.dump()`."""
2 +from __future__ import annotations
3 +
4 +from functools import lru_cache
5 +from pathlib import Path
6 +
7 +from pydantic import Field
8 +from pydantic_settings import BaseSettings, SettingsConfigDict
9 +
10 +
11 +class Settings(BaseSettings):
12 + model_config = SettingsConfigDict(env_file=(".env",), env_file_encoding="utf-8", extra="ignore")
13 +
14 + app_env: str = Field("development", alias="APP_ENV")
15 + app_domain: str = Field("www.satelliteindex.io", alias="APP_DOMAIN")
16 + site_url: str = Field("https://www.satelliteindex.io", alias="SI_SITE_URL")
17 +
18 + database_url: str = Field("postgresql+asyncpg://localhost:5432/satelliteindex", alias="DATABASE_URL")
19 + redis_url: str = Field("redis://127.0.0.1:6379/4", alias="REDIS_URL")
20 +
21 + data_dir: Path = Field(Path("./data"), alias="SI_DATA_DIR")
22 + api_host: str = Field("127.0.0.1", alias="SI_API_HOST")
23 + api_port: int = Field(8311, alias="SI_API_PORT")
24 + admin_token: str = Field("", alias="SI_ADMIN_TOKEN")
25 + log_json: bool = Field(True, alias="SI_LOG_JSON")
26 + tz: str = Field("America/Toronto", alias="SI_TZ")
27 +
28 + celestrak_base: str = Field("https://celestrak.org", alias="CELESTRAK_BASE")
29 + http_user_agent: str = Field("SatelliteIndex/0.1 (+https://www.satelliteindex.io; contact@spboucher.ai)", alias="SI_USER_AGENT")
30 + http_timeout_s: float = Field(90.0, alias="SI_HTTP_TIMEOUT")
31 +
32 + space_track_username: str = Field("", alias="SPACE_TRACK_USERNAME")
33 + space_track_password: str = Field("", alias="SPACE_TRACK_PASSWORD")
34 + discos_api_key: str = Field("", alias="DISCOS_API_KEY")
35 + scrapfly_api_key: str = Field("", alias="SCRAPFLY_API_KEY")
36 + firecrawl_api_key: str = Field("", alias="FIRECRAWL_API_KEY")
37 +
38 + # Freshness thresholds (seconds) for orbital data: fresh < aging < stale.
39 + orbit_aging_s: int = Field(12 * 3600, alias="SI_ORBIT_AGING_S")
40 + orbit_stale_s: int = Field(48 * 3600, alias="SI_ORBIT_STALE_S")
41 +
42 + # Positions cache TTL (seconds) for the globe batch endpoint.
43 + positions_ttl_s: int = Field(30, alias="SI_POSITIONS_TTL_S")
44 +
45 + @property
46 + def raw_dir(self) -> Path:
47 + return self.data_dir / "raw"
48 +
49 + @property
50 + def logs_dir(self) -> Path:
51 + return self.data_dir / "logs"
52 +
53 + @property
54 + def sync_database_url(self) -> str:
55 + return self.database_url.replace("+asyncpg", "")
56 +
57 + def ensure_dirs(self) -> None:
58 + for d in (self.raw_dir, self.logs_dir, self.data_dir / "backups", self.data_dir / "cache"):
59 + d.mkdir(parents=True, exist_ok=True)
60 +
61 +
62 +@lru_cache
63 +def get_settings() -> Settings:
64 + return Settings()
65 +
66 +
67 +settings = get_settings()
added src/satelliteindex/connectors/__init__.py +26 −0
@@ -0,0 +1,26 @@
1 +"""Connector registry: name → class. Add new connectors here (additive, never requires redesign)."""
2 +from __future__ import annotations
3 +
4 +from typing import Any
5 +
6 +from satelliteindex.connectors.base import BaseConnector
7 +
8 +
9 +def registry() -> dict[str, type[BaseConnector]]:
10 + from satelliteindex.connectors.orbital.celestrak.gp import CelesTrakGPConnector, CelesTrakGroupsConnector
11 + from satelliteindex.connectors.orbital.celestrak.satcat import CelesTrakSatcatConnector
12 + from satelliteindex.connectors.derived import DerivedAnalyticsConnector
13 +
14 + return {c.connector_name: c for c in (CelesTrakGPConnector, CelesTrakGroupsConnector, CelesTrakSatcatConnector, DerivedAnalyticsConnector)}
15 +
16 +
17 +async def build(name: str) -> BaseConnector:
18 + from satelliteindex.db import connection, fetch_one
19 +
20 + cls = registry().get(name)
21 + if cls is None:
22 + raise KeyError(f"unknown connector {name!r}; known: {', '.join(sorted(registry()))}")
23 + async with connection() as conn:
24 + row = await fetch_one(conn, "select config from connectors where name = :n", n=name)
25 + config: dict[str, Any] = dict(row["config"]) if row and row["config"] else {}
26 + return cls(config)
added src/satelliteindex/connectors/base/__init__.py +4 −0
@@ -0,0 +1,4 @@
1 +from satelliteindex.connectors.base.connector import BaseConnector, ConnectorError, RunContext, RunStats
2 +from satelliteindex.connectors.base.fetch import FetchError, FetchResult, NotModifiedError, fetch_url
3 +
4 +__all__ = ["BaseConnector", "ConnectorError", "RunContext", "RunStats", "FetchError", "FetchResult", "NotModifiedError", "fetch_url"]
added src/satelliteindex/connectors/base/connector.py +179 −0
@@ -0,0 +1,179 @@
1 +"""Connector framework: run bookkeeping, raw snapshots, hashing, circuit breaker. Every connector subclasses `BaseConnector`."""
2 +from __future__ import annotations
3 +
4 +import gzip
5 +import json
6 +import logging
7 +import time
8 +from dataclasses import dataclass, field
9 +from datetime import UTC, datetime, timedelta
10 +from pathlib import Path
11 +from typing import Any
12 +
13 +from sqlalchemy.ext.asyncio import AsyncConnection
14 +
15 +from satelliteindex.config import settings
16 +from satelliteindex.connectors.base.fetch import FetchError, FetchResult, fetch_url
17 +from satelliteindex.db import execute, fetch_one, transaction
18 +from satelliteindex.ids import new_id
19 +
20 +log = logging.getLogger(__name__)
21 +
22 +CIRCUIT_FAILURES = 3 # consecutive failures before the circuit opens
23 +CIRCUIT_COOLDOWN_S = 1800 # 30 min
24 +
25 +
26 +class ConnectorError(Exception):
27 + pass
28 +
29 +
30 +@dataclass
31 +class RunStats:
32 + fetched: int = 0
33 + created: int = 0
34 + updated: int = 0
35 + skipped: int = 0
36 + unchanged: bool = False
37 + payload_hash: str | None = None
38 + meta: dict[str, Any] = field(default_factory=dict)
39 +
40 + def add(self, other: RunStats) -> None:
41 + self.fetched += other.fetched
42 + self.created += other.created
43 + self.updated += other.updated
44 + self.skipped += other.skipped
45 +
46 +
47 +@dataclass
48 +class RunContext:
49 + run_id: str
50 + connector_name: str
51 + source_id: str
52 + started_at: datetime
53 + stats: RunStats = field(default_factory=RunStats)
54 + log: logging.LoggerAdapter[logging.Logger] = field(init=False)
55 +
56 + def __post_init__(self) -> None:
57 + self.log = logging.LoggerAdapter(logging.getLogger(f"connector.{self.connector_name}"),
58 + {"connector": self.connector_name, "run_id": self.run_id, "source": self.source_id})
59 +
60 +
61 +class BaseConnector:
62 + """Interface every connector implements. `run()` handles bookkeeping; subclasses implement `execute()`."""
63 +
64 + connector_name: str = ""
65 + source_id: str = ""
66 + update_interval_seconds: int = 3600
67 +
68 + def __init__(self, config: dict[str, Any] | None = None):
69 + self.config = config or {}
70 +
71 + # ------------------------------------------------------------------ to implement
72 + async def execute(self, ctx: RunContext) -> RunStats:
73 + raise NotImplementedError
74 +
75 + async def healthcheck(self) -> bool:
76 + return True
77 +
78 + # ------------------------------------------------------------------ helpers
79 + async def fetch(self, ctx: RunContext, url: str, *, min_bytes: int = 1) -> FetchResult:
80 + res = await fetch_url(url, min_bytes=min_bytes)
81 + ctx.log.info("fetched", extra={"url": url, "status": res.status, "bytes": len(res.content), "ms": res.duration_ms})
82 + return res
83 +
84 + async def previous_hash(self, conn: AsyncConnection, native_id: str) -> str | None:
85 + row = await fetch_one(conn, """select payload_hash from raw_records where connector_name = :c and source_native_id = :n
86 + and processing_status in ('processed','unchanged') order by fetched_at desc limit 1""",
87 + c=self.connector_name, n=native_id)
88 + return row["payload_hash"] if row else None
89 +
90 + async def store_raw(self, conn: AsyncConnection, ctx: RunContext, res: FetchResult, *, native_id: str, record_count: int | None = None,
91 + status: str = "processed", keep_payload: bool = True) -> str:
92 + """Persist the raw snapshot (gzip on disk under SI_DATA_DIR/raw/<connector>/<date>/) and its DB record."""
93 + raw_id = new_id("raw_record")
94 + rel: str | None = None
95 + if keep_payload:
96 + day = res.fetched_at.strftime("%Y-%m-%d")
97 + folder = settings.raw_dir / self.connector_name / day
98 + folder.mkdir(parents=True, exist_ok=True)
99 + ext = "json" if "json" in res.content_type or res.content[:1] in (b"[", b"{") else "csv" if "csv" in res.content_type or native_id.endswith(".csv") else "bin"
100 + path = folder / f"{native_id.replace('/', '_')}-{res.fetched_at.strftime('%H%M%S')}.{ext}.gz"
101 + with gzip.open(path, "wb", compresslevel=6) as fh:
102 + fh.write(res.content)
103 + rel = str(path.relative_to(settings.raw_dir))
104 + await execute(conn, """
105 + insert into raw_records (id, source_id, connector_name, run_id, source_native_id, content_type, payload_hash, byte_size, storage_path, source_url,
106 + fetched_at, processed_at, processing_status, record_count)
107 + values (:id, :source_id, :connector_name, :run_id, :native_id, :content_type, :hash, :size, :path, :url, :fetched_at, now(), :status, :count)""",
108 + id=raw_id, source_id=self.source_id, connector_name=self.connector_name, run_id=ctx.run_id, native_id=native_id,
109 + content_type=res.content_type.split(";")[0], hash=res.sha256, size=len(res.content), path=rel, url=res.url,
110 + fetched_at=res.fetched_at, status=status, count=record_count)
111 + return raw_id
112 +
113 + # ------------------------------------------------------------------ run orchestration
114 + async def run(self, *, force: bool = False) -> RunContext:
115 + started = datetime.now(UTC)
116 + run_id = new_id("connector_run")
117 + ctx = RunContext(run_id=run_id, connector_name=self.connector_name, source_id=self.source_id, started_at=started)
118 + async with transaction() as conn:
119 + state = await fetch_one(conn, "select enabled, circuit_open_until, consecutive_failures from connectors where name = :n", n=self.connector_name)
120 + if state is None:
121 + raise ConnectorError(f"connector {self.connector_name} not registered (run `si seed`)")
122 + if not state["enabled"] and not force:
123 + ctx.log.info("connector disabled, skipping")
124 + await self._record(conn, ctx, "skipped", error="disabled")
125 + return ctx
126 + if state["circuit_open_until"] and state["circuit_open_until"] > started and not force:
127 + ctx.log.warning("circuit open, skipping", extra={"until": state["circuit_open_until"].isoformat()})
128 + await self._record(conn, ctx, "skipped", error="circuit open")
129 + return ctx
130 + await execute(conn, """insert into connector_runs (id, connector_name, source_id, started_at, status) values (:id, :c, :s, :t, 'running')""",
131 + id=run_id, c=self.connector_name, s=self.source_id, t=started)
132 + await execute(conn, "update connectors set last_attempt_at = :t where name = :n", t=started, n=self.connector_name)
133 +
134 + t0 = time.perf_counter()
135 + try:
136 + stats = await self.execute(ctx)
137 + ctx.stats = stats
138 + status = "unchanged" if stats.unchanged else "success"
139 + async with transaction() as conn:
140 + await self._record(conn, ctx, status)
141 + await execute(conn, """update connectors set consecutive_failures = 0, circuit_open_until = null, last_success_at = now(),
142 + last_duration_ms = :d, next_run_at = now() + make_interval(secs => interval_seconds), updated_at = now() where name = :n""",
143 + d=int((time.perf_counter() - t0) * 1000), n=self.connector_name)
144 + ctx.log.info("run finished", extra={"status": status, "fetched": stats.fetched, "created": stats.created, "updated": stats.updated,
145 + "skipped": stats.skipped, "ms": int((time.perf_counter() - t0) * 1000)})
146 + except Exception as exc: # noqa: BLE001 — every failure is recorded
147 + ctx.log.exception("run failed", extra={"error": str(exc)})
148 + async with transaction() as conn:
149 + await self._record(conn, ctx, "failed", error=f"{exc.__class__.__name__}: {exc}"[:2000])
150 + await execute(conn, """insert into connector_errors (connector_name, run_id, error_type, message) values (:n, :r, :t, :m)""",
151 + n=self.connector_name, r=run_id, t=exc.__class__.__name__, m=str(exc)[:4000])
152 + row = await fetch_one(conn, "update connectors set consecutive_failures = consecutive_failures + 1, last_duration_ms = :d, updated_at = now() where name = :n returning consecutive_failures",
153 + d=int((time.perf_counter() - t0) * 1000), n=self.connector_name)
154 + if row and row["consecutive_failures"] >= CIRCUIT_FAILURES:
155 + until = datetime.now(UTC) + timedelta(seconds=CIRCUIT_COOLDOWN_S)
156 + await execute(conn, "update connectors set circuit_open_until = :u where name = :n", u=until, n=self.connector_name)
157 + ctx.log.error("circuit opened", extra={"until": until.isoformat(), "failures": row["consecutive_failures"]})
158 + raise
159 + return ctx
160 +
161 + async def _record(self, conn: AsyncConnection, ctx: RunContext, status: str, *, error: str | None = None) -> None:
162 + s = ctx.stats
163 + await execute(conn, """
164 + insert into connector_runs (id, connector_name, source_id, started_at, finished_at, status, duration_ms, records_fetched, records_created,
165 + records_updated, records_skipped, error, payload_hash, meta)
166 + values (:id, :c, :src, :started, now(), :status, :dur, :f, :cr, :up, :sk, :err, :hash, cast(:meta as jsonb))
167 + on conflict (id) do update set finished_at = now(), status = excluded.status, duration_ms = excluded.duration_ms,
168 + records_fetched = excluded.records_fetched, records_created = excluded.records_created, records_updated = excluded.records_updated,
169 + records_skipped = excluded.records_skipped, error = excluded.error, payload_hash = excluded.payload_hash, meta = excluded.meta""",
170 + id=ctx.run_id, c=self.connector_name, src=self.source_id, started=ctx.started_at, status=status,
171 + dur=int((datetime.now(UTC) - ctx.started_at).total_seconds() * 1000), f=s.fetched, cr=s.created, up=s.updated, sk=s.skipped,
172 + err=error, hash=s.payload_hash, meta=json.dumps(s.meta, default=str))
173 +
174 +
175 +def raw_path(rel: str) -> Path:
176 + return settings.raw_dir / rel
177 +
178 +
179 +__all__ = ["BaseConnector", "ConnectorError", "FetchError", "RunContext", "RunStats", "raw_path"]
added src/satelliteindex/connectors/base/fetch.py +114 −0
@@ -0,0 +1,114 @@
1 +"""Generic fetch adapter: direct HTTP first, swappable providers later (Scrapfly / Firecrawl). Never escalates automatically."""
2 +from __future__ import annotations
3 +
4 +import hashlib
5 +import logging
6 +from dataclasses import dataclass
7 +from datetime import UTC, datetime
8 +
9 +import httpx
10 +from tenacity import AsyncRetrying, RetryError, retry_if_exception_type, stop_after_attempt, wait_exponential
11 +
12 +from satelliteindex.config import settings
13 +
14 +log = logging.getLogger(__name__)
15 +
16 +
17 +class FetchError(Exception):
18 + def __init__(self, message: str, *, status: int | None = None, url: str = ""):
19 + super().__init__(message)
20 + self.status = status
21 + self.url = url
22 +
23 +
24 +class RetryableFetchError(FetchError):
25 + pass
26 +
27 +
28 +class NotModifiedError(FetchError):
29 + """The source states the payload has not changed since our last download (e.g. CelesTrak 403 'has not updated')."""
30 +
31 +
32 +@dataclass
33 +class FetchResult:
34 + url: str
35 + status: int
36 + content: bytes
37 + content_type: str
38 + fetched_at: datetime
39 + duration_ms: int
40 + provider: str = "direct"
41 +
42 + @property
43 + def sha256(self) -> str:
44 + return hashlib.sha256(self.content).hexdigest()
45 +
46 + @property
47 + def text(self) -> str:
48 + return self.content.decode("utf-8", errors="replace")
49 +
50 +
51 +_client: httpx.AsyncClient | None = None
52 +
53 +
54 +def client() -> httpx.AsyncClient:
55 + global _client
56 + if _client is None or _client.is_closed:
57 + _client = httpx.AsyncClient(
58 + timeout=httpx.Timeout(settings.http_timeout_s, connect=20.0),
59 + headers={"User-Agent": settings.http_user_agent, "Accept-Encoding": "gzip, deflate, br"},
60 + follow_redirects=True, http2=False,
61 + )
62 + return _client
63 +
64 +
65 +async def close_client() -> None:
66 + global _client
67 + if _client is not None and not _client.is_closed:
68 + await _client.aclose()
69 + _client = None
70 +
71 +
72 +async def fetch_url(url: str, *, provider: str = "direct", retry: bool = True, attempts: int = 4, min_bytes: int = 1,
73 + headers: dict[str, str] | None = None) -> FetchResult:
74 + """Fetch a URL with exponential backoff on 429/5xx/network errors. Providers other than `direct` are not enabled yet."""
75 + if provider != "direct":
76 + raise FetchError(f"provider {provider!r} not configured", url=url)
77 + if url.startswith("file://"):
78 + from pathlib import Path
79 +
80 + path = Path(url[7:])
81 + data = path.read_bytes()
82 + ctype = "application/json" if path.suffix == ".json" else "text/csv" if path.suffix == ".csv" else "application/octet-stream"
83 + return FetchResult(url=url, status=200, content=data, content_type=ctype, fetched_at=datetime.now(UTC), duration_ms=0, provider="file")
84 +
85 + async def _once() -> FetchResult:
86 + started = datetime.now(UTC)
87 + try:
88 + resp = await client().get(url, headers=headers)
89 + except (httpx.TimeoutException, httpx.TransportError) as exc:
90 + raise RetryableFetchError(f"network error: {exc.__class__.__name__}", url=url) from exc
91 + dur = int((datetime.now(UTC) - started).total_seconds() * 1000)
92 + if resp.status_code in (429, 500, 502, 503, 504):
93 + raise RetryableFetchError(f"HTTP {resp.status_code}", status=resp.status_code, url=url)
94 + if resp.status_code == 403 and b"has not updated since your last" in resp.content:
95 + raise NotModifiedError("source reports no update since last download", status=403, url=url)
96 + if resp.status_code >= 400:
97 + raise FetchError(f"HTTP {resp.status_code}", status=resp.status_code, url=url)
98 + if len(resp.content) < min_bytes:
99 + # An unexpectedly empty body is suspicious; never treated as "source has no data".
100 + raise RetryableFetchError(f"body too small ({len(resp.content)} bytes)", status=resp.status_code, url=url)
101 + return FetchResult(url=url, status=resp.status_code, content=resp.content,
102 + content_type=resp.headers.get("content-type", "application/octet-stream"),
103 + fetched_at=started, duration_ms=dur)
104 +
105 + if not retry:
106 + return await _once()
107 + try:
108 + async for attempt in AsyncRetrying(stop=stop_after_attempt(attempts), wait=wait_exponential(multiplier=2, min=2, max=60),
109 + retry=retry_if_exception_type(RetryableFetchError), reraise=True):
110 + with attempt:
111 + return await _once()
112 + except RetryError as exc: # pragma: no cover
113 + raise FetchError(str(exc), url=url) from exc
114 + raise FetchError("unreachable", url=url) # pragma: no cover
added src/satelliteindex/connectors/derived.py +43 −0
@@ -0,0 +1,43 @@
1 +"""Derived analytics "connector": refresh materialized views, stats snapshot, search index, freshness flags, activity scores."""
2 +from __future__ import annotations
3 +
4 +from satelliteindex.analytics.search import rebuild_search_index
5 +from satelliteindex.analytics.stats import compute_global_stats, refresh_matviews
6 +from satelliteindex.connectors.base import BaseConnector, RunContext, RunStats
7 +from satelliteindex.db import execute, fetch_val, transaction
8 +
9 +
10 +class DerivedAnalyticsConnector(BaseConnector):
11 + connector_name = "derived_analytics"
12 + source_id = "satelliteindex"
13 + update_interval_seconds = 3600
14 +
15 + async def execute(self, ctx: RunContext) -> RunStats:
16 + stats = RunStats()
17 + async with transaction() as conn:
18 + await refresh_matviews(conn)
19 + async with transaction() as conn:
20 + snap = await compute_global_stats(conn)
21 + await rebuild_search_index(conn)
22 + # stale orbital data flags: active objects whose latest element set is older than 30 days
23 + await execute(conn, """
24 + insert into data_quality_flags (entity_type, entity_id, flag, detail)
25 + select 'satellite', id, 'STALE_DATA', 'latest element set older than 30 days' from satellites
26 + where status = 'ACTIVE' and has_gp and latest_epoch < now() - interval '30 days'
27 + on conflict (entity_type, entity_id, flag) do update set resolved_at = null""")
28 + await execute(conn, """update data_quality_flags f set resolved_at = now() from satellites s
29 + where f.entity_type = 'satellite' and f.entity_id = s.id and f.flag = 'STALE_DATA' and f.resolved_at is null
30 + and (s.latest_epoch >= now() - interval '30 days' or s.status <> 'ACTIVE')""")
31 + # duplicate-name payload pairs (same normalized name, both on orbit, different NORAD) → review queue (never auto-merge)
32 + await execute(conn, """
33 + insert into manual_review_queue (kind, entity_a_type, entity_a_id, entity_b_type, entity_b_id, confidence, detail)
34 + select 'possible_duplicate', 'satellite', a.id, 'satellite', b.id, 0.5,
35 + jsonb_build_object('name', a.canonical_name, 'norad_a', a.norad_id, 'norad_b', b.norad_id)
36 + from satellites a join satellites b on a.normalized_name = b.normalized_name and a.id < b.id
37 + where a.object_type = 'PAYLOAD' and b.object_type = 'PAYLOAD' and a.decay_date is null and b.decay_date is null
38 + and a.cospar_id is distinct from b.cospar_id and a.canonical_name !~ '^(OBJECT|TBA|UNKNOWN)'
39 + and not exists (select 1 from manual_review_queue q where q.entity_a_id = a.id and q.entity_b_id = b.id)
40 + limit 500""")
41 + stats.fetched = int(await fetch_val(conn, "select count(*) from search_index"))
42 + stats.meta = {"active_satellites": snap["global"]["active_satellites"], "objects_on_orbit": snap["global"]["objects_on_orbit"]}
43 + return stats
added src/satelliteindex/connectors/orbital/__init__.py +0 −0
added src/satelliteindex/connectors/orbital/celestrak/__init__.py +0 −0
added src/satelliteindex/connectors/orbital/celestrak/gp.py +265 −0
@@ -0,0 +1,265 @@
1 +"""CelesTrak GP (general perturbations) connectors — OMM JSON.
2 +
3 +* `celestrak_gp` : the `active` group → orbital_elements history + orbital_state (latest) + derived orbit geometry.
4 +* `celestrak_groups` : thematic groups → satellite_tags + constellation membership (source-backed).
5 +Only changed payloads (content hash) are processed. Objects missing from a group are never deleted.
6 +"""
7 +from __future__ import annotations
8 +
9 +import asyncio
10 +import json
11 +from datetime import UTC, datetime
12 +from typing import Any
13 +
14 +from satelliteindex.config import settings
15 +from satelliteindex.connectors.base import BaseConnector, NotModifiedError, RunContext, RunStats
16 +from satelliteindex.db import execute, execute_many, fetch_all, transaction
17 +from satelliteindex.orbital.elements import classify_orbit, derived_geometry
18 +from satelliteindex.services.classify import Classifier
19 +from satelliteindex.services.events import emit_events
20 +from satelliteindex.services.resolution import SatIndex, apply_updates, create_satellites, record_provenance
21 +
22 +MANEUVER_KM = 25.0 # perigee/apogee change between consecutive element sets that we report as ORBIT_CHANGE
23 +
24 +
25 +def gp_url(group: str, config: dict[str, Any] | None = None) -> str:
26 + local = (config or {}).get("local_files") or {}
27 + if group in local:
28 + return f"file://{local[group]}"
29 + return f"{settings.celestrak_base}/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
30 +
31 +
32 +def parse_omm(text: str) -> list[dict[str, Any]]:
33 + data = json.loads(text)
34 + if not isinstance(data, list):
35 + raise ValueError("OMM payload is not a JSON array")
36 + out = []
37 + for d in data:
38 + try:
39 + out.append({
40 + "name": str(d["OBJECT_NAME"]).strip(), "cospar": (d.get("OBJECT_ID") or "").strip() or None, "norad": int(d["NORAD_CAT_ID"]),
41 + "epoch": datetime.fromisoformat(d["EPOCH"]).replace(tzinfo=UTC), "mean_motion": float(d["MEAN_MOTION"]),
42 + "ecc": float(d["ECCENTRICITY"]), "incl": float(d["INCLINATION"]), "raan": float(d["RA_OF_ASC_NODE"]),
43 + "argp": float(d["ARG_OF_PERICENTER"]), "ma": float(d["MEAN_ANOMALY"]), "bstar": float(d.get("BSTAR") or 0.0),
44 + "ndot": float(d.get("MEAN_MOTION_DOT") or 0.0), "nddot": float(d.get("MEAN_MOTION_DDOT") or 0.0),
45 + "elset": int(d.get("ELEMENT_SET_NO") or 0), "rev": int(d.get("REV_AT_EPOCH") or 0),
46 + "cls": d.get("CLASSIFICATION_TYPE"), "eph": int(d.get("EPHEMERIS_TYPE") or 0), "raw": d,
47 + })
48 + except (KeyError, ValueError, TypeError):
49 + continue
50 + return out
51 +
52 +
53 +class CelesTrakGPConnector(BaseConnector):
54 + connector_name = "celestrak_gp"
55 + source_id = "celestrak"
56 + update_interval_seconds = 7200
57 +
58 + async def execute(self, ctx: RunContext) -> RunStats:
59 + total = RunStats()
60 + groups = self.config.get("groups") or ["active"]
61 + all_unchanged = True
62 + for group in groups:
63 + s = await self._ingest_group(ctx, group)
64 + total.add(s)
65 + all_unchanged = all_unchanged and s.unchanged
66 + total.meta[group] = s.meta
67 + total.unchanged = all_unchanged
68 + return total
69 +
70 + async def _ingest_group(self, ctx: RunContext, group: str) -> RunStats:
71 + stats = RunStats()
72 + try:
73 + res = await self.fetch(ctx, gp_url(group, self.config), min_bytes=200)
74 + except NotModifiedError:
75 + ctx.log.info("source reports no update", extra={"group": group})
76 + stats.unchanged = True
77 + return stats
78 + stats.payload_hash = res.sha256
79 + async with transaction() as conn:
80 + if await self.previous_hash(conn, group) == res.sha256:
81 + await self.store_raw(conn, ctx, res, native_id=group, status="unchanged", keep_payload=False)
82 + stats.unchanged = True
83 + return stats
84 + records = parse_omm(res.text)
85 + stats.fetched = len(records)
86 + if group == "active" and len(records) < 5000:
87 + raise ValueError(f"suspicious `active` GP size: {len(records)} objects")
88 +
89 + async with transaction() as conn:
90 + raw_id = await self.store_raw(conn, ctx, res, native_id=group, record_count=len(records))
91 + idx = await SatIndex.load(conn)
92 + clf = await Classifier.load(conn)
93 + state = {r["satellite_id"]: r for r in await fetch_all(conn, "select satellite_id, epoch, perigee_km, apogee_km, orbit_class from orbital_state")}
94 +
95 + new_rows = []
96 + for r in records:
97 + existing, _ = idx.resolve(norad_id=r["norad"], cospar_id=r["cospar"], name=r["name"])
98 + if existing is None:
99 + cls = clf.classify(r["name"], None, "PAYLOAD", group_hint=group if group != "active" else None)
100 + new_rows.append({"canonical_name": r["name"], "norad_id": r["norad"], "cospar_id": r["cospar"], "object_type": "PAYLOAD",
101 + "status": "ACTIVE" if group == "active" else "UNKNOWN", "has_gp": True, "operator_id": cls["operator_id"],
102 + "constellation_id": cls["constellation_id"], "country_code": cls["country_code"], "mission_type": cls["mission_type"]})
103 + stats.created = await create_satellites(conn, idx, new_rows, self.source_id)
104 +
105 + elements: list[dict[str, Any]] = []
106 + updates: list[tuple[dict[str, Any], dict[str, Any]]] = []
107 + events: list[dict[str, Any]] = []
108 + now = datetime.now(UTC)
109 + for r in records:
110 + sat = idx.by_norad.get(r["norad"])
111 + if sat is None:
112 + stats.skipped += 1
113 + continue
114 + geo = derived_geometry(r["mean_motion"], r["ecc"])
115 + oc = classify_orbit(period_minutes=geo["period_minutes"], eccentricity=r["ecc"], inclination_deg=r["incl"],
116 + apogee_km=geo["apogee_km"], perigee_km=geo["perigee_km"])
117 + elements.append({"sid": sat["id"], "src": self.source_id, "epoch": r["epoch"], "mm": r["mean_motion"], "ecc": r["ecc"], "incl": r["incl"],
118 + "raan": r["raan"], "argp": r["argp"], "ma": r["ma"], "bstar": r["bstar"], "ndot": r["ndot"], "nddot": r["nddot"],
119 + "elset": r["elset"], "rev": r["rev"], "cls": r["cls"], "eph": r["eph"], "a": geo["semi_major_axis_km"],
120 + "peri": geo["perigee_km"], "apo": geo["apogee_km"], "period": geo["period_minutes"], "oc": oc,
121 + "raw": json.dumps(r["raw"], separators=(",", ":"))})
122 + prev = state.get(sat["id"])
123 + changes: dict[str, Any] = {"has_gp": True}
124 + if prev is None or r["epoch"] > prev["epoch"]:
125 + changes.update({"latest_epoch": r["epoch"], "orbit_class": oc, "period_minutes": round(geo["period_minutes"], 3),
126 + "inclination_deg": r["incl"], "apogee_km": round(geo["apogee_km"], 1), "perigee_km": round(geo["perigee_km"], 1)})
127 + if prev is not None and prev["perigee_km"] is not None:
128 + dp, da = geo["perigee_km"] - prev["perigee_km"], geo["apogee_km"] - (prev["apogee_km"] or geo["apogee_km"])
129 + if abs(dp) >= MANEUVER_KM or abs(da) >= MANEUVER_KM:
130 + direction = "raised" if (dp + da) > 0 else "lowered"
131 + events.append({"type": "ORBIT_CHANGE", "title": f"{sat['canonical_name']} {direction} its orbit",
132 + "summary": f"Perigee {prev['perigee_km']:.0f} → {geo['perigee_km']:.0f} km, apogee {(prev['apogee_km'] or 0):.0f} → {geo['apogee_km']:.0f} km between element sets (epoch {r['epoch']:%Y-%m-%d %H:%M} UTC).",
133 + "event_time": r["epoch"], "source_id": self.source_id, "dedupe_key": f"orbit:{sat['id']}:{r['epoch']:%Y-%m-%dT%H}",
134 + "confidence": 0.7, "metadata": {"norad_id": r["norad"], "d_perigee_km": round(dp, 1), "d_apogee_km": round(da, 1)},
135 + "entities": [("satellite", sat["id"], "subject")]})
136 + if prev["orbit_class"] and prev["orbit_class"] != oc:
137 + events.append({"type": "ORBIT_CHANGE", "title": f"{sat['canonical_name']} moved from {prev['orbit_class']} to {oc}",
138 + "summary": "Orbit class changed between consecutive element sets.", "event_time": r["epoch"], "source_id": self.source_id,
139 + "dedupe_key": f"class:{sat['id']}:{oc}", "confidence": 0.8, "metadata": {"norad_id": r["norad"]},
140 + "entities": [("satellite", sat["id"], "subject")]})
141 + if group == "active" and sat.get("status") in ("UNKNOWN", None) and sat.get("decay_date") is None:
142 + changes["status"] = "ACTIVE"
143 + if sat.get("cospar_id") is None and r["cospar"]:
144 + changes["cospar_id"] = r["cospar"]
145 + updates.append((sat, changes))
146 +
147 + # append-only history (unique on satellite/source/epoch)
148 + await execute_many(conn, """
149 + insert into orbital_elements (satellite_id, source_id, epoch, mean_motion, eccentricity, inclination, raan, arg_of_perigee, mean_anomaly, bstar,
150 + mean_motion_dot, mean_motion_ddot, element_set_no, rev_at_epoch, classification, ephemeris_type, semi_major_axis_km, perigee_km, apogee_km,
151 + period_minutes, element_format, raw_omm)
152 + values (:sid, :src, :epoch, :mm, :ecc, :incl, :raan, :argp, :ma, :bstar, :ndot, :nddot, :elset, :rev, :cls, :eph, :a, :peri, :apo, :period, 'omm_json', cast(:raw as jsonb))
153 + on conflict (satellite_id, source_id, epoch) do nothing""", elements, chunk=1000)
154 + # latest state: pick the newest element per satellite among those just inserted
155 + sids = [e["sid"] for e in elements]
156 + await execute(conn, """
157 + insert into orbital_state (satellite_id, element_id, source_id, epoch, mean_motion, eccentricity, inclination, raan, arg_of_perigee, mean_anomaly,
158 + bstar, mean_motion_dot, mean_motion_ddot, semi_major_axis_km, perigee_km, apogee_km, period_minutes, orbit_class, updated_at)
159 + select distinct on (satellite_id) satellite_id, id, source_id, epoch, mean_motion, eccentricity, inclination, raan, arg_of_perigee, mean_anomaly,
160 + bstar, mean_motion_dot, mean_motion_ddot, semi_major_axis_km, perigee_km, apogee_km, period_minutes,
161 + case when abs(period_minutes - 1436.07) <= 30 and eccentricity < 0.05 and inclination < 20 then 'GEO'
162 + when eccentricity > 0.25 and apogee_km > 35000 then 'HEO'
163 + when apogee_km < 2000 then 'LEO'
164 + when perigee_km >= 2000 and apogee_km < 37786 then (case when abs(period_minutes - 1436.07) <= 60 then 'GEO' else 'MEO' end)
165 + when abs(period_minutes - 1436.07) <= 60 and eccentricity < 0.1 then 'GEO'
166 + else 'OTHER' end,
167 + now()
168 + from orbital_elements where satellite_id = any(:sids) order by satellite_id, epoch desc
169 + on conflict (satellite_id) do update set element_id = excluded.element_id, source_id = excluded.source_id, epoch = excluded.epoch,
170 + mean_motion = excluded.mean_motion, eccentricity = excluded.eccentricity, inclination = excluded.inclination, raan = excluded.raan,
171 + arg_of_perigee = excluded.arg_of_perigee, mean_anomaly = excluded.mean_anomaly, bstar = excluded.bstar, mean_motion_dot = excluded.mean_motion_dot,
172 + mean_motion_ddot = excluded.mean_motion_ddot, semi_major_axis_km = excluded.semi_major_axis_km, perigee_km = excluded.perigee_km,
173 + apogee_km = excluded.apogee_km, period_minutes = excluded.period_minutes, orbit_class = excluded.orbit_class, updated_at = now()
174 + where excluded.epoch > orbital_state.epoch""", sids=sids)
175 + stats.updated = await apply_updates(conn, idx, updates, self.source_id)
176 + await execute(conn, "update satellites set last_seen_at = now() where id = any(:ids)", ids=sids)
177 + # group tag for `active` as well (source-backed "active" flag)
178 + await execute_many(conn, """insert into satellite_tags (satellite_id, tag, source_id) values (:sid, :tag, :src)
179 + on conflict (satellite_id, tag) do update set last_seen_at = now()""",
180 + [{"sid": s, "tag": group, "src": self.source_id} for s in set(sids)])
181 + await record_provenance(conn, [{"entity_type": "satellite", "entity_id": s, "field_name": "orbital_elements", "field_value": None,
182 + "source_id": self.source_id, "source_record_id": raw_id, "confidence": 1.0} for s in set(sids)])
183 + await emit_events(conn, events)
184 + await execute(conn, "update raw_records set processed_at = now(), processing_status = 'processed' where id = :id", id=raw_id)
185 + stats.meta = {"objects": len(records), "elements": len(elements), "events": len(events)}
186 + return stats
187 +
188 +
189 +class CelesTrakGroupsConnector(BaseConnector):
190 + connector_name = "celestrak_groups"
191 + source_id = "celestrak"
192 + update_interval_seconds = 7200
193 +
194 + async def execute(self, ctx: RunContext) -> RunStats:
195 + total = RunStats()
196 + groups: list[str] = self.config.get("groups") or []
197 + all_unchanged = True
198 + for group in groups:
199 + try:
200 + s = await self._ingest_group(ctx, group)
201 + except Exception as exc: # noqa: BLE001 — one failing group must not abort the others
202 + ctx.log.warning("group failed", extra={"group": group, "error": str(exc)})
203 + total.meta[group] = {"error": str(exc)[:200]}
204 + all_unchanged = False
205 + continue
206 + total.add(s)
207 + all_unchanged = all_unchanged and s.unchanged
208 + total.meta[group] = s.meta or {"unchanged": True}
209 + await asyncio.sleep(0.5) # be polite
210 + total.unchanged = all_unchanged
211 + return total
212 +
213 + async def _ingest_group(self, ctx: RunContext, group: str) -> RunStats:
214 + stats = RunStats()
215 + try:
216 + res = await self.fetch(ctx, gp_url(group, self.config), min_bytes=2)
217 + except NotModifiedError:
218 + ctx.log.info("source reports no update", extra={"group": group})
219 + stats.unchanged = True
220 + return stats
221 + stats.payload_hash = res.sha256
222 + async with transaction() as conn:
223 + if await self.previous_hash(conn, group) == res.sha256:
224 + await self.store_raw(conn, ctx, res, native_id=group, status="unchanged", keep_payload=False)
225 + stats.unchanged = True
226 + return stats
227 + records = parse_omm(res.text)
228 + stats.fetched = len(records)
229 + async with transaction() as conn:
230 + raw_id = await self.store_raw(conn, ctx, res, native_id=group, record_count=len(records))
231 + idx = await SatIndex.load(conn)
232 + clf = await Classifier.load(conn)
233 + spec_const = clf.classify("", None, "PAYLOAD", group_hint=group)
234 + const_id = spec_const["constellation_id"] if spec_const["method"] == "celestrak_group" else None
235 + new_rows, updates, tags, members = [], [], [], []
236 + for r in records:
237 + sat, _ = idx.resolve(norad_id=r["norad"], cospar_id=r["cospar"], name=r["name"])
238 + if sat is None:
239 + cls = clf.classify(r["name"], None, "PAYLOAD", group_hint=group)
240 + new_rows.append({"canonical_name": r["name"], "norad_id": r["norad"], "cospar_id": r["cospar"],
241 + "object_type": "UNKNOWN" if group == "analyst" or r["norad"] >= 80000 else "PAYLOAD",
242 + "status": "UNKNOWN", "has_gp": False, "operator_id": cls["operator_id"], "constellation_id": cls["constellation_id"],
243 + "country_code": cls["country_code"], "mission_type": cls["mission_type"]})
244 + stats.created = await create_satellites(conn, idx, new_rows, self.source_id)
245 + for r in records:
246 + sat = idx.by_norad.get(r["norad"])
247 + if sat is None:
248 + continue
249 + tags.append({"sid": sat["id"], "tag": group, "src": self.source_id})
250 + if const_id and sat.get("constellation_id") != const_id:
251 + cls = clf.classify(r["name"], sat.get("owner_code"), sat.get("object_type") or "PAYLOAD", group_hint=group)
252 + updates.append((sat, {"constellation_id": const_id, "operator_id": cls["operator_id"] or sat.get("operator_id"),
253 + "mission_type": cls["mission_type"] if sat.get("mission_type") in (None, "unknown") else sat.get("mission_type")}))
254 + members.append({"sid": sat["id"], "cid": const_id})
255 + await execute_many(conn, """insert into satellite_tags (satellite_id, tag, source_id) values (:sid, :tag, :src)
256 + on conflict (satellite_id, tag) do update set last_seen_at = now()""", tags)
257 + stats.updated = await apply_updates(conn, idx, updates, self.source_id)
258 + if members:
259 + await execute_many(conn, "update constellation_memberships set until = now() where satellite_id = :sid and until is null and constellation_id <> :cid", members)
260 + await execute_many(conn, """insert into constellation_memberships (satellite_id, constellation_id, method)
261 + select :sid, :cid, 'celestrak_group' where not exists (
262 + select 1 from constellation_memberships where satellite_id = :sid and constellation_id = :cid and until is null)""", members)
263 + await execute(conn, "update raw_records set processed_at = now(), processing_status = 'processed' where id = :id", id=raw_id)
264 + stats.meta = {"objects": len(records), "constellation": const_id, "reassigned": len(members)}
265 + return stats
added src/satelliteindex/connectors/orbital/celestrak/satcat.py +274 −0
@@ -0,0 +1,274 @@
1 +"""CelesTrak SATCAT connector — the catalog backbone: every catalogued object (type, owner, launch, decay, ops status, orbit summary).
2 +
3 +Format: https://celestrak.org/satcat/satcat-format.php (CSV). One run = full snapshot diffed against the canonical table:
4 +new objects are created, changed fields are updated with history, objects missing upstream are NEVER deleted.
5 +"""
6 +from __future__ import annotations
7 +
8 +import csv
9 +import io
10 +from collections import defaultdict
11 +from datetime import UTC, date, datetime
12 +from typing import Any
13 +
14 +from satelliteindex.config import settings
15 +from satelliteindex.connectors.base import BaseConnector, ConnectorError, RunContext, RunStats
16 +from satelliteindex.db import execute, execute_many, fetch_all, transaction
17 +from satelliteindex.ids import new_id
18 +from satelliteindex.orbital.elements import classify_from_satcat
19 +from satelliteindex.registry.reference import LAUNCH_SITES, OWNER_CODES, SATCAT_OBJECT_TYPES, SATCAT_STATUS
20 +from satelliteindex.services.classify import Classifier, object_type_from_name
21 +from satelliteindex.services.events import emit_events
22 +from satelliteindex.services.resolution import SatIndex, apply_updates, create_satellites, flags_bulk, record_provenance, touch_seen
23 +
24 +MIN_EXPECTED_ROWS = 50_000 # the catalog has > 60 000 rows; anything far below is a truncated/suspicious response
25 +
26 +
27 +def _f(v: str) -> float | None:
28 + v = v.strip()
29 + if not v:
30 + return None
31 + try:
32 + return float(v)
33 + except ValueError:
34 + return None
35 +
36 +
37 +def _d(v: str) -> date | None:
38 + v = v.strip()
39 + if not v:
40 + return None
41 + try:
42 + return date.fromisoformat(v)
43 + except ValueError:
44 + return None
45 +
46 +
47 +def _i(v: str) -> int | None:
48 + v = v.strip()
49 + return int(v) if v.isdigit() else None
50 +
51 +
52 +class CelesTrakSatcatConnector(BaseConnector):
53 + connector_name = "celestrak_satcat"
54 + source_id = "celestrak_satcat"
55 + update_interval_seconds = 86400
56 +
57 + def parse(self, text: str) -> list[dict[str, Any]]:
58 + reader = csv.DictReader(io.StringIO(text))
59 + rows = []
60 + for r in reader:
61 + norad = _i(r.get("NORAD_CAT_ID", ""))
62 + if norad is None:
63 + continue
64 + rows.append({
65 + "name": (r.get("OBJECT_NAME") or "").strip() or f"OBJECT {norad}",
66 + "cospar": (r.get("OBJECT_ID") or "").strip() or None,
67 + "norad": norad,
68 + "type": SATCAT_OBJECT_TYPES.get((r.get("OBJECT_TYPE") or "").strip(), "UNKNOWN"),
69 + "ops": (r.get("OPS_STATUS_CODE") or "").strip(),
70 + "owner": (r.get("OWNER") or "").strip() or None,
71 + "launch_date": _d(r.get("LAUNCH_DATE", "")),
72 + "site": (r.get("LAUNCH_SITE") or "").strip() or None,
73 + "decay": _d(r.get("DECAY_DATE", "")),
74 + "period": _f(r.get("PERIOD", "")),
75 + "incl": _f(r.get("INCLINATION", "")),
76 + "apogee": _f(r.get("APOGEE", "")),
77 + "perigee": _f(r.get("PERIGEE", "")),
78 + "rcs": _f(r.get("RCS", "")),
79 + "center": (r.get("ORBIT_CENTER") or "").strip() or None,
80 + "otype": (r.get("ORBIT_TYPE") or "").strip() or None,
81 + })
82 + return rows
83 +
84 + async def execute(self, ctx: RunContext) -> RunStats:
85 + stats = RunStats()
86 + url = settings.celestrak_base + self.config.get("url", "/pub/satcat.csv")
87 + res = await self.fetch(ctx, url, min_bytes=1_000_000)
88 + stats.payload_hash = res.sha256
89 + async with transaction() as conn:
90 + prev = await self.previous_hash(conn, "satcat.csv")
91 + if prev == res.sha256:
92 + await self.store_raw(conn, ctx, res, native_id="satcat.csv", status="unchanged", keep_payload=False)
93 + stats.unchanged = True
94 + return stats
95 + rows = self.parse(res.text)
96 + stats.fetched = len(rows)
97 + if len(rows) < MIN_EXPECTED_ROWS:
98 + raise ConnectorError(f"suspicious SATCAT size: {len(rows)} rows (< {MIN_EXPECTED_ROWS}); not applied")
99 +
100 + async with transaction() as conn:
101 + raw_id = await self.store_raw(conn, ctx, res, native_id="satcat.csv", record_count=len(rows))
102 + idx = await SatIndex.load(conn)
103 + clf = await Classifier.load(conn)
104 + unknown_owners: set[str] = set()
105 + unknown_sites: set[str] = set()
106 + for r in rows:
107 + if r["owner"] and r["owner"] not in OWNER_CODES:
108 + unknown_owners.add(r["owner"])
109 + if r["site"] and r["site"] not in LAUNCH_SITES:
110 + unknown_sites.add(r["site"])
111 + if unknown_owners:
112 + await execute_many(conn, """insert into owner_codes (code, name, kind) values (:c, :n, 'unknown') on conflict (code) do nothing""",
113 + [{"c": c, "n": f"Unknown owner code {c}"} for c in unknown_owners])
114 + clf = await Classifier.load(conn)
115 + if unknown_sites:
116 + await execute_many(conn, """insert into launch_sites (code, name, slug, active) values (:c, :n, :s, true) on conflict (code) do nothing""",
117 + [{"c": c, "n": f"Launch site {c}", "s": f"site-{c.lower()}"} for c in unknown_sites])
118 +
119 + launches = await self._ensure_launches(conn, rows)
120 +
121 + new_rows: list[dict[str, Any]] = []
122 + updates: list[tuple[dict[str, Any], dict[str, Any]]] = []
123 + seen_ids: list[str] = []
124 + events: list[dict[str, Any]] = []
125 + prov: list[dict[str, Any]] = []
126 + flags: list[dict[str, Any]] = []
127 + now = datetime.now(UTC)
128 + new_by_launch: dict[str, list[str]] = defaultdict(list)
129 + for r in rows:
130 + obj_type = object_type_from_name(r["name"], r["type"])
131 + status = SATCAT_STATUS.get(r["ops"], "UNKNOWN")
132 + if r["decay"] is not None:
133 + status = "DECAYED"
134 + elif obj_type in ("DEBRIS", "ROCKET_BODY") and status == "UNKNOWN":
135 + status = "INACTIVE"
136 + cls = clf.classify(r["name"], r["owner"], obj_type)
137 + orbit_class = classify_from_satcat(r["period"], r["incl"], r["apogee"], r["perigee"]) if r["center"] in (None, "EA") else "OTHER"
138 + launch_key = r["cospar"][:8] if r["cospar"] and len(r["cospar"]) >= 8 else None
139 + launch_id = launches.get(launch_key) if launch_key else None
140 + existing, method = idx.resolve(norad_id=r["norad"], cospar_id=r["cospar"], name=r["name"])
141 + desired = {
142 + "canonical_name": r["name"], "cospar_id": r["cospar"], "object_type": obj_type, "status": status, "ops_status_code": r["ops"] or None,
143 + "owner_code": r["owner"], "country_code": cls["country_code"], "operator_id": cls["operator_id"],
144 + "constellation_id": cls["constellation_id"], "launch_id": launch_id, "launch_date": r["launch_date"], "launch_site_code": r["site"],
145 + "decay_date": r["decay"], "mission_type": cls["mission_type"], "rcs_m2": r["rcs"], "orbit_center": r["center"], "orbit_type": r["otype"],
146 + }
147 + if existing is None:
148 + new_rows.append({**desired, "norad_id": r["norad"], "orbit_class": orbit_class, "period_minutes": r["period"],
149 + "inclination_deg": r["incl"], "apogee_km": r["apogee"], "perigee_km": r["perigee"]})
150 + if launch_key and obj_type in ("PAYLOAD", "STATION") and r["launch_date"] and (date.today() - r["launch_date"]).days <= 60:
151 + new_by_launch[launch_key].append(r["name"])
152 + continue
153 + seen_ids.append(existing["id"])
154 + changes = dict(desired)
155 + # GP-derived orbit numbers are fresher: only fill from SATCAT when the object has no element set.
156 + if not existing.get("has_gp"):
157 + changes.update({"orbit_class": orbit_class, "period_minutes": r["period"], "inclination_deg": r["incl"],
158 + "apogee_km": r["apogee"], "perigee_km": r["perigee"]})
159 + # Keep a constellation assigned by a CelesTrak group unless SATCAT/name gives one too.
160 + if changes["constellation_id"] is None and existing.get("constellation_id"):
161 + changes.pop("constellation_id")
162 + changes.pop("operator_id", None) if existing.get("operator_id") else None
163 + if existing.get("mission_type") not in (None, "unknown"):
164 + changes.pop("mission_type", None)
165 + if changes.get("operator_id") is None and existing.get("operator_id"):
166 + changes.pop("operator_id", None)
167 + if changes.get("country_code") is None and existing.get("country_code"):
168 + changes.pop("country_code", None)
169 + real = {k: v for k, v in changes.items() if existing.get(k) != v}
170 + if real:
171 + sid = existing["id"]
172 + if "status" in real:
173 + old, new = existing.get("status"), real["status"]
174 + if new == "DECAYED":
175 + events.append({"type": "DECAY", "title": f"{r['name']} decayed", "summary": f"NORAD {r['norad']} re-entered / decayed on {r['decay']}.",
176 + "event_time": datetime.combine(r["decay"], datetime.min.time(), UTC) if r["decay"] else now,
177 + "source_id": self.source_id, "dedupe_key": f"decay:{sid}", "confidence": 0.95,
178 + "metadata": {"norad_id": r["norad"], "decay_date": str(r["decay"])}, "entities": [("satellite", sid, "subject")]})
179 + elif new == "INACTIVE" and old == "ACTIVE":
180 + events.append({"type": "SATELLITE_DECOMMISSION", "title": f"{r['name']} reported inactive", "summary": f"SATCAT status changed {existing.get('ops_status_code') or '?'} → {r['ops'] or '?'}.",
181 + "event_time": now, "source_id": self.source_id, "dedupe_key": f"status:{sid}:{now:%Y-%m-%d}:INACTIVE", "confidence": 0.8,
182 + "metadata": {"norad_id": r["norad"], "from": old, "to": new}, "entities": [("satellite", sid, "subject")]})
183 + elif new == "ACTIVE" and old in ("INACTIVE", "UNKNOWN"):
184 + events.append({"type": "SATELLITE_ACTIVATION", "title": f"{r['name']} reported operational", "summary": f"SATCAT status changed {existing.get('ops_status_code') or '?'} → {r['ops'] or '?'}.",
185 + "event_time": now, "source_id": self.source_id, "dedupe_key": f"status:{sid}:{now:%Y-%m-%d}:ACTIVE", "confidence": 0.8,
186 + "metadata": {"norad_id": r["norad"], "from": old, "to": new}, "entities": [("satellite", sid, "subject")]})
187 + updates.append((existing, real))
188 + if r["owner"] in ("UNK", "TBD", None):
189 + flags.append({"t": "satellite", "i": existing["id"], "f": "UNKNOWN_COUNTRY", "d": f"SATCAT owner {r['owner']}"})
190 + if not r["cospar"]:
191 + flags.append({"t": "satellite", "i": existing["id"], "f": "MISSING_ID", "d": "no COSPAR id in SATCAT"})
192 +
193 + stats.created = await create_satellites(conn, idx, new_rows, self.source_id)
194 + stats.updated = await apply_updates(conn, idx, updates, self.source_id)
195 + stats.skipped = len(rows) - stats.created - stats.updated
196 + await touch_seen(conn, seen_ids)
197 + await flags_bulk(conn, flags)
198 +
199 + # provenance for the SATCAT-owned fields (one row per field per satellite is too heavy for 70 k objects every day:
200 + # we record it for the record-level fields on changed/new objects only)
201 + changed_ids = [rec["id"] for rec in new_rows if "id" in rec] + [row["id"] for row, _ in updates]
202 + for sid in changed_ids[:20000]:
203 + for f in ("canonical_name", "object_type", "status", "owner_code", "launch_date", "decay_date"):
204 + prov.append({"entity_type": "satellite", "entity_id": sid, "field_name": f, "field_value": None, "source_id": self.source_id,
205 + "source_record_id": raw_id, "confidence": 1.0})
206 + await record_provenance(conn, prov)
207 +
208 + # catalogue events: new payloads grouped by launch (recent launches only)
209 + for lk, names in new_by_launch.items():
210 + lid = launches.get(lk)
211 + first = names[0]
212 + title = f"{len(names)} new payload{'s' if len(names) > 1 else ''} catalogued from launch {lk}" if len(names) > 1 else f"{first} catalogued (launch {lk})"
213 + events.append({"type": "SATELLITE_LAUNCH", "title": title, "summary": ", ".join(names[:12]) + (" …" if len(names) > 12 else ""),
214 + "event_time": now, "source_id": self.source_id, "dedupe_key": f"catalogued:{lk}:{now:%Y-%m-%d}", "confidence": 0.9,
215 + "metadata": {"launch": lk, "count": len(names)}, "entities": [("launch", lid, "subject")] if lid else []})
216 + await emit_events(conn, events)
217 + await self._refresh_launch_counts(conn)
218 + await execute(conn, "update raw_records set processed_at = now(), processing_status = 'processed' where id = :id", id=raw_id)
219 + stats.meta = {"rows": len(rows), "new": stats.created, "changed": stats.updated, "events": len(events), "unknown_owners": sorted(unknown_owners)}
220 + return stats
221 +
222 + async def _ensure_launches(self, conn, rows: list[dict[str, Any]]) -> dict[str, str]:
223 + """Launches are derived from the international designator prefix (YYYY-NNN). Returns cospar_launch_id → id."""
224 + existing = {r["cospar_launch_id"]: r["id"] for r in await fetch_all(conn, "select id, cospar_launch_id from launches")}
225 + agg: dict[str, dict[str, Any]] = {}
226 + for r in rows:
227 + c = r["cospar"]
228 + if not c or len(c) < 8:
229 + continue
230 + key = c[:8]
231 + a = agg.setdefault(key, {"date": None, "site": None, "owners": set(), "payloads": 0, "objects": 0, "on_orbit": 0, "primary": None})
232 + a["objects"] += 1
233 + if r["decay"] is None:
234 + a["on_orbit"] += 1
235 + if r["type"] == "PAYLOAD":
236 + a["payloads"] += 1
237 + if a["primary"] is None or c.endswith("A"):
238 + a["primary"] = r["name"]
239 + if r["launch_date"] and a["date"] is None:
240 + a["date"] = r["launch_date"]
241 + if r["site"] and a["site"] is None:
242 + a["site"] = r["site"]
243 + if r["owner"]:
244 + a["owners"].add(r["owner"])
245 + new = []
246 + for key, a in agg.items():
247 + if key in existing:
248 + continue
249 + lid = new_id("launch")
250 + existing[key] = lid
251 + new.append({"id": lid, "key": key, "date": a["date"], "year": int(key[:4]) if key[:4].isdigit() else None, "site": a["site"],
252 + "owners": sorted(a["owners"]), "payloads": a["payloads"], "objects": a["objects"], "on_orbit": a["on_orbit"], "primary": a["primary"]})
253 + if new:
254 + await execute_many(conn, """insert into launches (id, cospar_launch_id, launch_date, launch_year, launch_site_code, owner_codes, payload_count, object_count, on_orbit_count, primary_name)
255 + values (:id, :key, :date, :year, :site, :owners, :payloads, :objects, :on_orbit, :primary) on conflict (cospar_launch_id) do nothing""", new)
256 + return existing
257 +
258 + async def _refresh_launch_counts(self, conn) -> None:
259 + await execute(conn, """
260 + update launches l set
261 + payload_count = s.payloads, object_count = s.objects, on_orbit_count = s.on_orbit,
262 + launch_date = coalesce(l.launch_date, s.launch_date), launch_site_code = coalesce(l.launch_site_code, s.site),
263 + primary_name = coalesce(s.primary_name, l.primary_name), owner_codes = s.owners, updated_at = now()
264 + from (
265 + select launch_id,
266 + count(*) filter (where object_type in ('PAYLOAD','STATION')) as payloads,
267 + count(*) as objects,
268 + count(*) filter (where decay_date is null) as on_orbit,
269 + min(launch_date) as launch_date,
270 + min(launch_site_code) as site,
271 + (array_agg(canonical_name order by (cospar_id like '%A') desc, cospar_id) filter (where object_type in ('PAYLOAD','STATION')))[1] as primary_name,
272 + array_remove(array_agg(distinct owner_code), null) as owners
273 + from satellites where launch_id is not null group by launch_id
274 + ) s where s.launch_id = l.id""")
added src/satelliteindex/db/__init__.py +66 −0
@@ -0,0 +1,66 @@
1 +"""Database access: one async engine per process, plain SQL through SQLAlchemy Core (`text`)."""
2 +from __future__ import annotations
3 +
4 +from collections.abc import AsyncIterator
5 +from contextlib import asynccontextmanager
6 +from typing import Any
7 +
8 +from sqlalchemy import text
9 +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
10 +
11 +from satelliteindex.config import settings
12 +
13 +_engine: AsyncEngine | None = None
14 +
15 +
16 +def engine() -> AsyncEngine:
17 + global _engine
18 + if _engine is None:
19 + _engine = create_async_engine(settings.database_url, pool_size=8, max_overflow=8, pool_pre_ping=True,
20 + pool_recycle=1800, connect_args={"server_settings": {"application_name": "satelliteindex"}})
21 + return _engine
22 +
23 +
24 +async def dispose() -> None:
25 + global _engine
26 + if _engine is not None:
27 + await _engine.dispose()
28 + _engine = None
29 +
30 +
31 +@asynccontextmanager
32 +async def connection() -> AsyncIterator[AsyncConnection]:
33 + async with engine().connect() as conn:
34 + yield conn
35 +
36 +
37 +@asynccontextmanager
38 +async def transaction() -> AsyncIterator[AsyncConnection]:
39 + async with engine().begin() as conn:
40 + yield conn
41 +
42 +
43 +async def fetch_all(conn: AsyncConnection, sql: str, **params: Any) -> list[dict[str, Any]]:
44 + res = await conn.execute(text(sql), params)
45 + return [dict(r) for r in res.mappings().all()]
46 +
47 +
48 +async def fetch_one(conn: AsyncConnection, sql: str, **params: Any) -> dict[str, Any] | None:
49 + res = await conn.execute(text(sql), params)
50 + row = res.mappings().first()
51 + return dict(row) if row is not None else None
52 +
53 +
54 +async def fetch_val(conn: AsyncConnection, sql: str, **params: Any) -> Any:
55 + res = await conn.execute(text(sql), params)
56 + return res.scalar()
57 +
58 +
59 +async def execute(conn: AsyncConnection, sql: str, **params: Any) -> int:
60 + res = await conn.execute(text(sql), params)
61 + return res.rowcount if res.rowcount is not None else 0
62 +
63 +
64 +async def execute_many(conn: AsyncConnection, sql: str, rows: list[dict[str, Any]], chunk: int = 2000) -> None:
65 + for i in range(0, len(rows), chunk):
66 + await conn.execute(text(sql), rows[i:i + chunk])
added src/satelliteindex/ids.py +43 −0
@@ -0,0 +1,43 @@
1 +"""Internal identifiers: prefixed ULIDs (`sat_01J8…`). NORAD/COSPAR are *source* identifiers, never primary keys."""
2 +from __future__ import annotations
3 +
4 +import re
5 +import unicodedata
6 +
7 +from ulid import ULID
8 +
9 +PREFIXES = {
10 + "satellite": "sat",
11 + "organization": "org",
12 + "constellation": "con",
13 + "launch": "lch",
14 + "event": "evt",
15 + "raw_record": "raw",
16 + "connector_run": "run",
17 + "source": "src",
18 +}
19 +
20 +
21 +def new_id(entity: str) -> str:
22 + return f"{PREFIXES[entity]}_{ULID()}"
23 +
24 +
25 +_slug_re = re.compile(r"[^a-z0-9]+")
26 +
27 +
28 +def slugify(text: str, *, max_len: int = 80) -> str:
29 + """ASCII, lowercase, hyphen-separated. `ISS (ZARYA)` → `iss-zarya`."""
30 + norm = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
31 + s = _slug_re.sub("-", norm.lower()).strip("-")
32 + return s[:max_len].rstrip("-") or "object"
33 +
34 +
35 +def normalize_name(text: str) -> str:
36 + """Comparison key: uppercase, collapsed whitespace, punctuation stripped."""
37 + norm = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
38 + return re.sub(r"[^A-Z0-9]+", " ", norm.upper()).strip()
39 +
40 +
41 +def satellite_slug(name: str, norad_id: int | None) -> str:
42 + base = slugify(name)
43 + return f"{base}-{norad_id}" if norad_id is not None else base
added src/satelliteindex/logging.py +53 −0
@@ -0,0 +1,53 @@
1 +"""Structured JSON logging (one object per line). Secrets are never logged: callers must not pass them."""
2 +from __future__ import annotations
3 +
4 +import json
5 +import logging
6 +import sys
7 +import time
8 +from typing import Any
9 +
10 +from satelliteindex.config import settings
11 +
12 +_RESERVED = {"name", "msg", "args", "levelname", "levelno", "pathname", "filename", "module", "exc_info", "exc_text",
13 + "stack_info", "lineno", "funcName", "created", "msecs", "relativeCreated", "thread", "threadName",
14 + "processName", "process", "message", "taskName"}
15 +
16 +
17 +class JsonFormatter(logging.Formatter):
18 + def format(self, record: logging.LogRecord) -> str: # noqa: D102
19 + payload: dict[str, Any] = {
20 + "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)) + f".{int(record.msecs):03d}Z",
21 + "level": record.levelname,
22 + "logger": record.name,
23 + "service": getattr(record, "service", "satelliteindex"),
24 + "msg": record.getMessage(),
25 + }
26 + for k, v in record.__dict__.items():
27 + if k not in _RESERVED and not k.startswith("_"):
28 + payload[k] = v
29 + if record.exc_info:
30 + payload["exc"] = self.formatException(record.exc_info)
31 + return json.dumps(payload, default=str, ensure_ascii=False)
32 +
33 +
34 +def setup_logging(level: int = logging.INFO, service: str = "satelliteindex") -> None:
35 + root = logging.getLogger()
36 + root.handlers.clear()
37 + handler = logging.StreamHandler(sys.stderr)
38 + if settings.log_json:
39 + handler.setFormatter(JsonFormatter())
40 + else:
41 + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
42 + root.addHandler(handler)
43 + root.setLevel(level)
44 + logging.getLogger("httpx").setLevel(logging.WARNING)
45 + logging.getLogger("apscheduler").setLevel(logging.WARNING)
46 + old_factory = logging.getLogRecordFactory()
47 +
48 + def factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
49 + rec = old_factory(*args, **kwargs)
50 + rec.service = service
51 + return rec
52 +
53 + logging.setLogRecordFactory(factory)
added src/satelliteindex/orbital/__init__.py +4 −0
@@ -0,0 +1,4 @@
1 +from satelliteindex.orbital.elements import classify_orbit, derived_geometry
2 +from satelliteindex.orbital.propagate import BatchPropagator, Elements, ground_track, propagate_one
3 +
4 +__all__ = ["classify_orbit", "derived_geometry", "BatchPropagator", "Elements", "ground_track", "propagate_one"]
added src/satelliteindex/orbital/elements.py +57 −0
@@ -0,0 +1,57 @@
1 +"""Orbital element helpers: derived geometry (semi-major axis, apsides, period) and orbit classification (metric `orbit_class` v1.0)."""
2 +from __future__ import annotations
3 +
4 +import math
5 +
6 +MU_EARTH_KM3_S2 = 398600.4418
7 +R_EARTH_KM = 6378.137
8 +GEO_RADIUS_KM = 42164.0
9 +GEO_ALTITUDE_KM = GEO_RADIUS_KM - R_EARTH_KM # 35 786 km
10 +SIDEREAL_DAY_MIN = 1436.07
11 +
12 +
13 +def semi_major_axis_km(mean_motion_rev_per_day: float) -> float:
14 + n = mean_motion_rev_per_day * 2.0 * math.pi / 86400.0 # rad/s
15 + return (MU_EARTH_KM3_S2 / (n * n)) ** (1.0 / 3.0)
16 +
17 +
18 +def derived_geometry(mean_motion: float, eccentricity: float) -> dict[str, float]:
19 + a = semi_major_axis_km(mean_motion)
20 + return {
21 + "semi_major_axis_km": a,
22 + "perigee_km": a * (1.0 - eccentricity) - R_EARTH_KM,
23 + "apogee_km": a * (1.0 + eccentricity) - R_EARTH_KM,
24 + "period_minutes": 1440.0 / mean_motion if mean_motion > 0 else float("nan"),
25 + }
26 +
27 +
28 +def classify_orbit(*, period_minutes: float | None, eccentricity: float | None, inclination_deg: float | None,
29 + apogee_km: float | None, perigee_km: float | None) -> str:
30 + """LEO / MEO / GEO / HEO / OTHER — see metric_definitions.orbit_class for the documented rule."""
31 + if apogee_km is None or perigee_km is None:
32 + return "OTHER"
33 + e = eccentricity if eccentricity is not None else 0.0
34 + if period_minutes is not None and abs(period_minutes - SIDEREAL_DAY_MIN) <= 30 and e < 0.05 and (inclination_deg is None or inclination_deg < 20):
35 + return "GEO"
36 + if e > 0.25 and apogee_km > 35000:
37 + return "HEO"
38 + if apogee_km < 2000:
39 + return "LEO"
40 + if perigee_km >= 2000 and apogee_km < GEO_ALTITUDE_KM + 2000:
41 + # Geosynchronous but inclined / drifting objects (graveyard, IGSO) are grouped with MEO-range objects only if far from GEO period.
42 + if period_minutes is not None and abs(period_minutes - SIDEREAL_DAY_MIN) <= 60:
43 + return "GEO"
44 + return "MEO"
45 + if period_minutes is not None and abs(period_minutes - SIDEREAL_DAY_MIN) <= 60 and e < 0.1:
46 + return "GEO"
47 + return "OTHER"
48 +
49 +
50 +def classify_from_satcat(period_minutes: float | None, inclination_deg: float | None, apogee_km: float | None, perigee_km: float | None) -> str:
51 + """Same rule fed from SATCAT columns (no eccentricity there → estimate from apsides)."""
52 + e = None
53 + if apogee_km is not None and perigee_km is not None:
54 + ra, rp = apogee_km + R_EARTH_KM, perigee_km + R_EARTH_KM
55 + if ra + rp > 0:
56 + e = (ra - rp) / (ra + rp)
57 + return classify_orbit(period_minutes=period_minutes, eccentricity=e, inclination_deg=inclination_deg, apogee_km=apogee_km, perigee_km=perigee_km)
added src/satelliteindex/orbital/propagate.py +147 −0
@@ -0,0 +1,147 @@
1 +"""SGP4 propagation (python-sgp4, vectorised) → TEME → ECEF → geodetic lat/lon/alt. Positions are computed on demand, never persisted."""
2 +from __future__ import annotations
3 +
4 +import math
5 +from dataclasses import dataclass
6 +from datetime import UTC, datetime, timedelta
7 +from typing import Any
8 +
9 +import numpy as np
10 +from sgp4.api import SGP4_ERRORS, Satrec, SatrecArray, jday
11 +
12 +from satelliteindex.orbital.elements import R_EARTH_KM
13 +
14 +WGS84_F = 1.0 / 298.257223563
15 +WGS84_E2 = WGS84_F * (2 - WGS84_F)
16 +
17 +
18 +@dataclass
19 +class Elements:
20 + """Minimal element set needed by SGP4 (from `orbital_state` / `orbital_elements`)."""
21 + satellite_id: str
22 + norad_id: int | None
23 + epoch: datetime
24 + mean_motion: float # rev/day
25 + eccentricity: float
26 + inclination: float # deg
27 + raan: float # deg
28 + arg_of_perigee: float # deg
29 + mean_anomaly: float # deg
30 + bstar: float | None = None
31 + mean_motion_dot: float | None = None
32 + mean_motion_ddot: float | None = None
33 +
34 + def satrec(self) -> Satrec:
35 + sat = Satrec()
36 + ep = self.epoch if self.epoch.tzinfo else self.epoch.replace(tzinfo=UTC)
37 + jd_epoch = _jd(ep) - 2433281.5 # SGP4 epoch: days since 1949 Dec 31 00:00 UT
38 + deg2rad = math.pi / 180.0
39 + xpdotp = 1440.0 / (2.0 * math.pi) # rev/day → rad/min
40 + sat.sgp4init(
41 + 2, # WGS-72 gravity model (standard for TLE/OMM)
42 + "i",
43 + self.norad_id or 0,
44 + jd_epoch,
45 + self.bstar or 0.0,
46 + (self.mean_motion_dot or 0.0) / (xpdotp * 1440.0),
47 + (self.mean_motion_ddot or 0.0) / (xpdotp * 1440.0 * 1440.0),
48 + self.eccentricity,
49 + self.arg_of_perigee * deg2rad,
50 + self.inclination * deg2rad,
51 + self.mean_anomaly * deg2rad,
52 + self.mean_motion / xpdotp,
53 + self.raan * deg2rad,
54 + )
55 + return sat
56 +
57 +
58 +def _jd(t: datetime) -> float:
59 + jd, fr = jday(t.year, t.month, t.day, t.hour, t.minute, t.second + t.microsecond / 1e6)
60 + return jd + fr
61 +
62 +
63 +def jd_fr(t: datetime) -> tuple[float, float]:
64 + t = t if t.tzinfo else t.replace(tzinfo=UTC)
65 + t = t.astimezone(UTC)
66 + return jday(t.year, t.month, t.day, t.hour, t.minute, t.second + t.microsecond / 1e6)
67 +
68 +
69 +def gmst_rad(jd_ut1: np.ndarray | float) -> np.ndarray | float:
70 + """Greenwich mean sidereal time (IAU 1982), radians."""
71 + t = (jd_ut1 - 2451545.0) / 36525.0
72 + sec = 67310.54841 + (876600.0 * 3600.0 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t
73 + return np.mod(np.deg2rad(np.mod(sec, 86400.0) / 240.0), 2 * np.pi)
74 +
75 +
76 +def teme_to_geodetic(r_teme: np.ndarray, jd: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
77 + """r_teme shape (..., 3) km, jd matching leading shape → (lat_deg, lon_deg, alt_km) WGS84. Polar motion ignored (~10 m)."""
78 + theta = gmst_rad(jd)
79 + c, s = np.cos(theta), np.sin(theta)
80 + x = c * r_teme[..., 0] + s * r_teme[..., 1]
81 + y = -s * r_teme[..., 0] + c * r_teme[..., 1]
82 + z = r_teme[..., 2]
83 + lon = np.degrees(np.arctan2(y, x))
84 + p = np.sqrt(x * x + y * y)
85 + lat = np.arctan2(z, p * (1 - WGS84_E2))
86 + for _ in range(5):
87 + sin_lat = np.sin(lat)
88 + n = R_EARTH_KM / np.sqrt(1 - WGS84_E2 * sin_lat * sin_lat)
89 + lat = np.arctan2(z + WGS84_E2 * n * sin_lat, p)
90 + sin_lat = np.sin(lat)
91 + n = R_EARTH_KM / np.sqrt(1 - WGS84_E2 * sin_lat * sin_lat)
92 + cos_lat = np.cos(lat)
93 + alt = np.where(np.abs(cos_lat) > 1e-10, p / cos_lat - n, np.abs(z) / np.abs(sin_lat) - n * (1 - WGS84_E2))
94 + return np.degrees(lat), lon, alt
95 +
96 +
97 +def propagate_one(el: Elements, t: datetime) -> dict[str, Any]:
98 + """Single object, single time → dict(lat, lon, altitude_km, velocity_km_s, error)."""
99 + sat = el.satrec()
100 + jd, fr = jd_fr(t)
101 + e, r, v = sat.sgp4(jd, fr)
102 + if e != 0:
103 + return {"error": SGP4_ERRORS.get(e, f"sgp4 error {e}")}
104 + lat, lon, alt = teme_to_geodetic(np.array(r, dtype=float), np.array(jd + fr))
105 + return {"lat": float(lat), "lon": float(lon), "altitude_km": float(alt), "velocity_km_s": float(np.linalg.norm(v)),
106 + "position_teme_km": [float(x) for x in r], "velocity_teme_km_s": [float(x) for x in v], "error": None}
107 +
108 +
109 +def ground_track(el: Elements, t0: datetime, *, minutes_before: int = 45, minutes_after: int = 90, step_s: int = 60) -> list[dict[str, Any]]:
110 + sat = el.satrec()
111 + times = [t0 + timedelta(seconds=s) for s in range(-minutes_before * 60, minutes_after * 60 + 1, step_s)]
112 + jds = np.array([sum(jd_fr(t)) for t in times])
113 + jd = np.floor(jds - 0.5) + 0.5
114 + fr = jds - jd
115 + e, r, v = SatrecArray([sat]).sgp4(jd, fr)
116 + r0 = r[0]
117 + ok = e[0] == 0
118 + lat, lon, alt = teme_to_geodetic(r0, jds)
119 + out = []
120 + for i, t in enumerate(times):
121 + if not ok[i]:
122 + continue
123 + out.append({"t": t.isoformat().replace("+00:00", "Z"), "lat": round(float(lat[i]), 4), "lon": round(float(lon[i]), 4),
124 + "alt": round(float(alt[i]), 1), "future": t >= t0})
125 + return out
126 +
127 +
128 +class BatchPropagator:
129 + """Holds `SatrecArray` for many objects; `positions(t)` returns arrays for the globe (≈16 k objects in well under a second)."""
130 +
131 + def __init__(self, elements: list[Elements]):
132 + self.elements = elements
133 + self.ids = [e.satellite_id for e in elements]
134 + self.norads = np.array([e.norad_id or 0 for e in elements], dtype=np.int64)
135 + self.array = SatrecArray([e.satrec() for e in elements]) if elements else None
136 +
137 + def positions(self, t: datetime) -> dict[str, np.ndarray]:
138 + if self.array is None:
139 + return {"lat": np.array([]), "lon": np.array([]), "alt": np.array([]), "vel": np.array([]), "ok": np.array([], dtype=bool)}
140 + jd, fr = jd_fr(t)
141 + e, r, v = self.array.sgp4(np.array([jd]), np.array([fr]))
142 + r = r[:, 0, :]
143 + v = v[:, 0, :]
144 + ok = (e[:, 0] == 0) & np.all(np.isfinite(r), axis=1)
145 + lat, lon, alt = teme_to_geodetic(r, np.array(jd + fr))
146 + vel = np.linalg.norm(v, axis=1)
147 + return {"lat": lat, "lon": lon, "alt": alt, "vel": vel, "ok": ok}
added src/satelliteindex/registry/__init__.py +103 −0
@@ -0,0 +1,103 @@
1 +"""Curated registry loaders (constellations, organizations, reference data) and the seed routine."""
2 +from __future__ import annotations
3 +
4 +import re
5 +from dataclasses import dataclass, field
6 +from functools import lru_cache
7 +from pathlib import Path
8 +from typing import Any
9 +
10 +import yaml
11 +
12 +REGISTRY_DIR = Path(__file__).parent
13 +
14 +
15 +@dataclass(frozen=True)
16 +class OrgSpec:
17 + slug: str
18 + name: str
19 + kind: str
20 + country: str | None
21 + aliases: tuple[str, ...] = ()
22 + url: str | None = None
23 +
24 +
25 +@dataclass(frozen=True)
26 +class ConstellationSpec:
27 + slug: str
28 + name: str
29 + operator: str | None
30 + country: str | None
31 + service: str | None
32 + orbit: str | None
33 + patterns: tuple[re.Pattern[str], ...] = ()
34 + celestrak_groups: tuple[str, ...] = ()
35 + description: str | None = None
36 + url: str | None = None
37 + planned: int | None = None
38 + raw_patterns: tuple[str, ...] = field(default=())
39 +
40 +
41 +@dataclass(frozen=True)
42 +class MissionPattern:
43 + type: str
44 + patterns: tuple[re.Pattern[str], ...]
45 +
46 +
47 +@lru_cache
48 +def _load() -> dict[str, Any]:
49 + with (REGISTRY_DIR / "constellations.yaml").open("r", encoding="utf-8") as fh:
50 + return yaml.safe_load(fh)
51 +
52 +
53 +@lru_cache
54 +def organizations() -> list[OrgSpec]:
55 + out = []
56 + for o in _load()["organizations"]:
57 + out.append(OrgSpec(slug=o["slug"], name=o["name"], kind=o.get("kind", "operator"), country=o.get("country"),
58 + aliases=tuple(o.get("aliases") or ()), url=o.get("url")))
59 + return out
60 +
61 +
62 +@lru_cache
63 +def constellations() -> list[ConstellationSpec]:
64 + out = []
65 + for c in _load()["constellations"]:
66 + pats = tuple(c.get("patterns") or ())
67 + out.append(ConstellationSpec(
68 + slug=c["slug"], name=c["name"], operator=c.get("operator"), country=c.get("country"),
69 + service=c.get("service"), orbit=c.get("orbit"),
70 + patterns=tuple(re.compile(p, re.I) for p in pats), raw_patterns=pats,
71 + celestrak_groups=tuple(c.get("celestrak_groups") or ()),
72 + description=c.get("description"), url=c.get("url"), planned=c.get("planned"),
73 + ))
74 + return out
75 +
76 +
77 +@lru_cache
78 +def mission_patterns() -> list[MissionPattern]:
79 + return [MissionPattern(type=m["type"], patterns=tuple(re.compile(p, re.I) for p in m["patterns"]))
80 + for m in _load()["mission_patterns"]]
81 +
82 +
83 +def match_constellation(name: str) -> ConstellationSpec | None:
84 + for c in constellations():
85 + for p in c.patterns:
86 + if p.search(name):
87 + return c
88 + return None
89 +
90 +
91 +def constellation_by_group(group: str) -> ConstellationSpec | None:
92 + for c in constellations():
93 + if group in c.celestrak_groups:
94 + return c
95 + return None
96 +
97 +
98 +def match_mission(name: str) -> str | None:
99 + for m in mission_patterns():
100 + for p in m.patterns:
101 + if p.search(name):
102 + return m.type
103 + return None
added src/satelliteindex/registry/constellations.yaml +925 −0
@@ -0,0 +1,925 @@
1 +# Constellation & operator registry (curated). Assignment methods, in priority order:
2 +# 1. celestrak_groups — membership in a CelesTrak GP group (source-backed)
3 +# 2. patterns — regex on the SATCAT/GP object name (derived, documented in /methodology)
4 +# Each constellation belongs to an operator (organizations registry below) and has a service type.
5 +# Counts, statuses and orbits are NEVER written here: they come from the catalog.
6 +
7 +organizations:
8 + - slug: spacex
9 + name: SpaceX
10 + aliases: [Space Exploration Technologies Corp., Space Exploration Technologies]
11 + kind: operator
12 + country: US
13 + url: https://www.spacex.com
14 + - slug: eutelsat-oneweb
15 + name: Eutelsat OneWeb
16 + aliases: [OneWeb, Network Access Associates]
17 + kind: operator
18 + country: GB
19 + url: https://oneweb.net
20 + - slug: amazon-kuiper
21 + name: Amazon (Project Kuiper)
22 + aliases: [Kuiper Systems, Amazon Leo]
23 + kind: operator
24 + country: US
25 + url: https://www.aboutamazon.com/what-we-do/devices-services/project-kuiper
26 + - slug: shanghai-spacecom
27 + name: Shanghai Spacecom Satellite Technology (SSST)
28 + aliases: [SpaceSail, Qianfan]
29 + kind: operator
30 + country: CN
31 + - slug: china-satnet
32 + name: China Satellite Network Group (SatNet)
33 + aliases: [Guowang, Hulianwang]
34 + kind: operator
35 + country: CN
36 + - slug: planet-labs
37 + name: Planet Labs PBC
38 + aliases: [Planet]
39 + kind: operator
40 + country: US
41 + url: https://www.planet.com
42 + - slug: spire-global
43 + name: Spire Global
44 + aliases: [Spire]
45 + kind: operator
46 + country: US
47 + url: https://spire.com
48 + - slug: iridium
49 + name: Iridium Communications
50 + aliases: [Iridium]
51 + kind: operator
52 + country: US
53 + url: https://www.iridium.com
54 + - slug: globalstar
55 + name: Globalstar
56 + kind: operator
57 + country: US
58 + url: https://www.globalstar.com
59 + - slug: orbcomm
60 + name: ORBCOMM
61 + kind: operator
62 + country: US
63 + - slug: ses
64 + name: SES
65 + aliases: [SES S.A., O3b Networks]
66 + kind: operator
67 + country: LU
68 + url: https://www.ses.com
69 + - slug: intelsat
70 + name: Intelsat
71 + aliases: [International Telecommunications Satellite Organization]
72 + kind: operator
73 + country: US
74 + url: https://www.intelsat.com
75 + - slug: eutelsat
76 + name: Eutelsat
77 + aliases: [European Telecommunications Satellite Organization]
78 + kind: operator
79 + country: FR
80 + url: https://www.eutelsat.com
81 + - slug: viasat
82 + name: Viasat
83 + aliases: [Inmarsat]
84 + kind: operator
85 + country: US
86 + url: https://www.viasat.com
87 + - slug: telesat
88 + name: Telesat
89 + kind: operator
90 + country: CA
91 + url: https://www.telesat.com
92 + - slug: iceye
93 + name: ICEYE
94 + kind: operator
95 + country: FI
96 + url: https://www.iceye.com
97 + - slug: capella-space
98 + name: Capella Space
99 + kind: operator
100 + country: US
101 + - slug: blacksky
102 + name: BlackSky
103 + kind: operator
104 + country: US
105 + - slug: hawkeye-360
106 + name: HawkEye 360
107 + kind: operator
108 + country: US
109 + - slug: satellogic
110 + name: Satellogic
111 + kind: operator
112 + country: UY
113 + - slug: swarm-technologies
114 + name: Swarm Technologies (SpaceX)
115 + kind: operator
116 + country: US
117 + - slug: ast-spacemobile
118 + name: AST SpaceMobile
119 + kind: operator
120 + country: US
121 + - slug: kineis
122 + name: Kinéis
123 + kind: operator
124 + country: FR
125 + - slug: us-space-force
126 + name: United States Space Force
127 + aliases: [USSF, US Air Force Space Command]
128 + kind: military
129 + country: US
130 + - slug: nro
131 + name: National Reconnaissance Office
132 + aliases: [NRO]
133 + kind: military
134 + country: US
135 + - slug: euspa
136 + name: European Union Agency for the Space Programme (EUSPA)
137 + aliases: [EUSPA, European GNSS Agency]
138 + kind: agency
139 + country: null
140 + - slug: cnsa
141 + name: China National Space Administration
142 + aliases: [CNSA]
143 + kind: agency
144 + country: CN
145 + - slug: casc
146 + name: China Aerospace Science and Technology Corporation
147 + aliases: [CASC]
148 + kind: operator
149 + country: CN
150 + - slug: pla-ssf
151 + name: People's Liberation Army (Strategic Support Force)
152 + aliases: [PLA]
153 + kind: military
154 + country: CN
155 + - slug: roscosmos
156 + name: Roscosmos
157 + kind: agency
158 + country: RU
159 + - slug: russian-mod
160 + name: Russian Ministry of Defence
161 + aliases: [VKS, Russian Aerospace Forces]
162 + kind: military
163 + country: RU
164 + - slug: nasa
165 + name: NASA
166 + aliases: [National Aeronautics and Space Administration]
167 + kind: agency
168 + country: US
169 + - slug: noaa
170 + name: NOAA
171 + aliases: [National Oceanic and Atmospheric Administration]
172 + kind: agency
173 + country: US
174 + - slug: esa
175 + name: European Space Agency
176 + aliases: [ESA]
177 + kind: agency
178 + country: null
179 + - slug: eumetsat
180 + name: EUMETSAT
181 + kind: agency
182 + country: null
183 + - slug: jaxa
184 + name: JAXA
185 + aliases: [Japan Aerospace Exploration Agency]
186 + kind: agency
187 + country: JP
188 + - slug: isro
189 + name: ISRO
190 + aliases: [Indian Space Research Organisation]
191 + kind: agency
192 + country: IN
193 + - slug: csa
194 + name: Canadian Space Agency
195 + aliases: [CSA, ASC]
196 + kind: agency
197 + country: CA
198 + - slug: cnes
199 + name: CNES
200 + aliases: [Centre national d'études spatiales]
201 + kind: agency
202 + country: FR
203 + - slug: dlr
204 + name: DLR
205 + kind: agency
206 + country: DE
207 + - slug: kari
208 + name: Korea AeroSpace Administration (KASA / KARI)
209 + aliases: [KARI, KASA]
210 + kind: agency
211 + country: KR
212 + - slug: china-changguang
213 + name: Chang Guang Satellite Technology
214 + aliases: [CGSTL]
215 + kind: operator
216 + country: CN
217 + - slug: geespace
218 + name: Geespace (Geely)
219 + kind: operator
220 + country: CN
221 + - slug: guodian-gaoke
222 + name: Guodian Gaoke
223 + aliases: [Tianqi]
224 + kind: operator
225 + country: CN
226 + - slug: galaxy-space
227 + name: GalaxySpace
228 + kind: operator
229 + country: CN
230 + - slug: rocket-lab
231 + name: Rocket Lab
232 + kind: launch_provider
233 + country: US
234 + url: https://www.rocketlabusa.com
235 + - slug: yaogan-program
236 + name: China military / Yaogan programme
237 + kind: military
238 + country: CN
239 + - slug: telespazio
240 + name: Telespazio
241 + kind: operator
242 + country: IT
243 + - slug: hispasat
244 + name: Hispasat
245 + kind: operator
246 + country: ES
247 + - slug: sky-perfect-jsat
248 + name: SKY Perfect JSAT
249 + kind: operator
250 + country: JP
251 + - slug: echostar
252 + name: EchoStar / Hughes
253 + aliases: [Hughes Network Systems, DISH]
254 + kind: operator
255 + country: US
256 + - slug: sirius-xm
257 + name: Sirius XM
258 + kind: operator
259 + country: US
260 + - slug: yahsat
261 + name: Space42 (Yahsat)
262 + aliases: [Yahsat, Al Yah Satellite Communications]
263 + kind: operator
264 + country: AE
265 + - slug: arabsat
266 + name: Arabsat
267 + kind: operator
268 + country: SA
269 + - slug: china-satcom
270 + name: China Satcom
271 + kind: operator
272 + country: CN
273 + - slug: maxar
274 + name: Maxar Technologies
275 + aliases: [DigitalGlobe]
276 + kind: operator
277 + country: US
278 + - slug: airbus-defence-space
279 + name: Airbus Defence and Space
280 + kind: operator
281 + country: FR
282 + - slug: earth-i-surrey
283 + name: Surrey Satellite Technology
284 + kind: manufacturer
285 + country: GB
286 + - slug: astrocast
287 + name: Astrocast
288 + kind: operator
289 + country: CH
290 + - slug: myriota
291 + name: Myriota
292 + kind: operator
293 + country: AU
294 + - slug: lynk
295 + name: Lynk Global
296 + kind: operator
297 + country: US
298 + - slug: sateliot
299 + name: Sateliot
300 + kind: operator
301 + country: ES
302 + - slug: china-yuanxin
303 + name: Shanghai Yuanxin Satellite Technology (Hongyun)
304 + kind: operator
305 + country: CN
306 + - slug: umbra
307 + name: Umbra
308 + kind: operator
309 + country: US
310 + - slug: gomspace
311 + name: GomSpace
312 + kind: operator
313 + country: DK
314 + - slug: hisdesat
315 + name: Hisdesat
316 + kind: operator
317 + country: ES
318 + - slug: gonets
319 + name: Gonets Satellite System
320 + kind: operator
321 + country: RU
322 + - slug: sitro-ag
323 + name: Sitro-AG (Sitronics)
324 + kind: operator
325 + country: RU
326 + - slug: aerospacelab
327 + name: Aerospacelab
328 + kind: operator
329 + country: BE
330 + - slug: loft-orbital
331 + name: Loft Orbital
332 + kind: operator
333 + country: US
334 + - slug: tomorrow-io
335 + name: Tomorrow.io
336 + kind: operator
337 + country: US
338 + - slug: muon-space
339 + name: Muon Space
340 + kind: operator
341 + country: US
342 + - slug: pixxel
343 + name: Pixxel
344 + kind: operator
345 + country: IN
346 + - slug: exolaunch
347 + name: Exolaunch
348 + kind: launch_provider
349 + country: DE
350 + - slug: ses-o3b
351 + name: SES (O3b mPOWER)
352 + kind: operator
353 + country: LU
354 + - slug: japan-qzss
355 + name: Cabinet Office of Japan (QZSS)
356 + kind: agency
357 + country: JP
358 + - slug: china-beidou
359 + name: China Satellite Navigation Office
360 + kind: agency
361 + country: CN
362 + - slug: iac-glonass
363 + name: Roscosmos / Russian Space Systems (GLONASS)
364 + kind: agency
365 + country: RU
366 + - slug: dish-echostar
367 + name: EchoStar
368 + kind: operator
369 + country: US
370 + - slug: dsi-italy
371 + name: Italian Space Agency (ASI)
372 + aliases: [ASI]
373 + kind: agency
374 + country: IT
375 + - slug: hulianwang-cn
376 + name: China SatNet / CASC (Hulianwang test)
377 + kind: operator
378 + country: CN
379 +
380 +constellations:
381 + - slug: starlink
382 + name: Starlink
383 + operator: spacex
384 + country: US
385 + service: communications
386 + orbit: LEO
387 + url: https://www.starlink.com
388 + celestrak_groups: [starlink]
389 + patterns: ['^STARLINK']
390 + description: SpaceX's low Earth orbit broadband constellation, the largest satellite constellation ever deployed.
391 + - slug: oneweb
392 + name: OneWeb
393 + operator: eutelsat-oneweb
394 + country: GB
395 + service: communications
396 + orbit: LEO
397 + celestrak_groups: [oneweb]
398 + patterns: ['^ONEWEB']
399 + description: Eutelsat OneWeb's LEO broadband constellation in ~1,200 km polar orbits.
400 + - slug: kuiper
401 + name: Amazon Leo (Project Kuiper)
402 + operator: amazon-kuiper
403 + country: US
404 + service: communications
405 + orbit: LEO
406 + celestrak_groups: [kuiper]
407 + patterns: ['^KUIPER', '^AMAZON LEO']
408 + planned: 3236
409 + description: Amazon's LEO broadband constellation, FCC-authorised for 3,236 satellites.
410 + - slug: qianfan
411 + name: Qianfan (Thousand Sails / SpaceSail)
412 + operator: shanghai-spacecom
413 + country: CN
414 + service: communications
415 + orbit: LEO
416 + celestrak_groups: [qianfan]
417 + patterns: ['^QIANFAN']
418 + planned: 14000
419 + - slug: guowang
420 + name: Guowang (Hulianwang)
421 + operator: china-satnet
422 + country: CN
423 + service: communications
424 + orbit: LEO
425 + celestrak_groups: [hulianwang]
426 + patterns: ['^HULIANWANG', '^GUOWANG']
427 + planned: 12992
428 + - slug: iridium-next
429 + name: Iridium NEXT
430 + operator: iridium
431 + country: US
432 + service: communications
433 + orbit: LEO
434 + celestrak_groups: [iridium-NEXT]
435 + patterns: ['^IRIDIUM']
436 + - slug: globalstar
437 + name: Globalstar
438 + operator: globalstar
439 + country: US
440 + service: communications
441 + orbit: LEO
442 + celestrak_groups: [globalstar]
443 + patterns: ['^GLOBALSTAR']
444 + - slug: orbcomm
445 + name: ORBCOMM
446 + operator: orbcomm
447 + country: US
448 + service: iot
449 + orbit: LEO
450 + celestrak_groups: [orbcomm]
451 + patterns: ['^ORBCOMM']
452 + - slug: planet-flock
453 + name: Planet Flock (Dove)
454 + operator: planet-labs
455 + country: US
456 + service: earth-observation
457 + orbit: LEO
458 + celestrak_groups: [planet]
459 + patterns: ['^FLOCK', '^DOVE']
460 + - slug: planet-skysat
461 + name: Planet SkySat
462 + operator: planet-labs
463 + country: US
464 + service: earth-observation
465 + orbit: LEO
466 + patterns: ['^SKYSAT']
467 + - slug: planet-pelican
468 + name: Planet Pelican / Tanager
469 + operator: planet-labs
470 + country: US
471 + service: earth-observation
472 + orbit: LEO
473 + patterns: ['^PELICAN', '^TANAGER']
474 + - slug: spire-lemur
475 + name: Spire Lemur
476 + operator: spire-global
477 + country: US
478 + service: earth-observation
479 + orbit: LEO
480 + celestrak_groups: [spire]
481 + patterns: ['^LEMUR']
482 + - slug: swarm
483 + name: Swarm (SpaceBEE)
484 + operator: swarm-technologies
485 + country: US
486 + service: iot
487 + orbit: LEO
488 + celestrak_groups: [swarm]
489 + patterns: ['^SPACEBEE']
490 + - slug: iceye
491 + name: ICEYE SAR
492 + operator: iceye
493 + country: FI
494 + service: earth-observation
495 + orbit: LEO
496 + patterns: ['^ICEYE']
497 + - slug: capella
498 + name: Capella SAR
499 + operator: capella-space
500 + country: US
501 + service: earth-observation
502 + orbit: LEO
503 + patterns: ['^CAPELLA', '^ACADIA']
504 + - slug: blacksky
505 + name: BlackSky Global / Gen-3
506 + operator: blacksky
507 + country: US
508 + service: earth-observation
509 + orbit: LEO
510 + patterns: ['^BLACKSKY', '^GLOBAL-\d+$']
511 + - slug: hawkeye-360
512 + name: HawkEye 360
513 + operator: hawkeye-360
514 + country: US
515 + service: earth-observation
516 + orbit: LEO
517 + patterns: ['^HAWK-?\d', '^HAWKEYE']
518 + - slug: satellogic-newsat
519 + name: Satellogic NewSat (Ñusat)
520 + operator: satellogic
521 + country: UY
522 + service: earth-observation
523 + orbit: LEO
524 + patterns: ['^NUSAT', '^ÑUSAT', '^NEWSAT']
525 + - slug: umbra
526 + name: Umbra SAR
527 + operator: umbra
528 + country: US
529 + service: earth-observation
530 + orbit: LEO
531 + patterns: ['^UMBRA']
532 + - slug: ast-bluebird
533 + name: AST SpaceMobile BlueBird
534 + operator: ast-spacemobile
535 + country: US
536 + service: communications
537 + orbit: LEO
538 + patterns: ['^BLUEBIRD', '^BLUEWALKER']
539 + - slug: kineis
540 + name: Kinéis IoT
541 + operator: kineis
542 + country: FR
543 + service: iot
544 + orbit: LEO
545 + patterns: ['^KINEIS']
546 + - slug: astrocast
547 + name: Astrocast
548 + operator: astrocast
549 + country: CH
550 + service: iot
551 + orbit: LEO
552 + patterns: ['^ASTROCAST']
553 + - slug: gps
554 + name: GPS (NAVSTAR)
555 + operator: us-space-force
556 + country: US
557 + service: navigation
558 + orbit: MEO
559 + celestrak_groups: [gps-ops]
560 + patterns: ['^NAVSTAR', '^GPS ']
561 + - slug: glonass
562 + name: GLONASS
563 + operator: iac-glonass
564 + country: RU
565 + service: navigation
566 + orbit: MEO
567 + celestrak_groups: [glo-ops]
568 + patterns: ['^COSMOS .*\(GLONASS', '^GLONASS']
569 + - slug: galileo
570 + name: Galileo
571 + operator: euspa
572 + country: null
573 + service: navigation
574 + orbit: MEO
575 + celestrak_groups: [galileo]
576 + patterns: ['^GSAT\d{4}', '^GALILEO']
577 + - slug: beidou
578 + name: BeiDou
579 + operator: china-beidou
580 + country: CN
581 + service: navigation
582 + orbit: MIXED
583 + celestrak_groups: [beidou]
584 + patterns: ['^BEIDOU']
585 + - slug: qzss
586 + name: QZSS (Michibiki)
587 + operator: japan-qzss
588 + country: JP
589 + service: navigation
590 + orbit: HEO
591 + patterns: ['^QZS']
592 + - slug: navic
593 + name: NavIC (IRNSS)
594 + operator: isro
595 + country: IN
596 + service: navigation
597 + orbit: GEO
598 + patterns: ['^IRNSS', '^NVS-']
599 + - slug: o3b
600 + name: SES O3b / O3b mPOWER
601 + operator: ses
602 + country: LU
603 + service: communications
604 + orbit: MEO
605 + patterns: ['^O3B']
606 + - slug: ses-geo
607 + name: SES GEO fleet
608 + operator: ses
609 + country: LU
610 + service: communications
611 + orbit: GEO
612 + celestrak_groups: [ses]
613 + patterns: ['^SES-', '^SES ', '^ASTRA ', '^NSS-', '^AMC-', '^QUETZSAT']
614 + - slug: intelsat
615 + name: Intelsat fleet
616 + operator: intelsat
617 + country: US
618 + service: communications
619 + orbit: GEO
620 + celestrak_groups: [intelsat]
621 + patterns: ['^INTELSAT', '^IS-\d', '^GALAXY \d', '^HORIZONS']
622 + - slug: eutelsat-geo
623 + name: Eutelsat GEO fleet
624 + operator: eutelsat
625 + country: FR
626 + service: communications
627 + orbit: GEO
628 + celestrak_groups: [eutelsat]
629 + patterns: ['^EUTELSAT', '^HOTBIRD', '^KONNECT', '^EUTE ']
630 + - slug: viasat
631 + name: Viasat / Inmarsat fleet
632 + operator: viasat
633 + country: US
634 + service: communications
635 + orbit: GEO
636 + patterns: ['^VIASAT', '^INMARSAT', '^WILDBLUE']
637 + - slug: telesat
638 + name: Telesat (Anik / Telstar / Lightspeed)
639 + operator: telesat
640 + country: CA
641 + service: communications
642 + orbit: MIXED
643 + celestrak_groups: [telesat]
644 + patterns: ['^ANIK', '^TELSTAR', '^NIMIQ', '^LIGHTSPEED', '^LEO VANTAGE']
645 + - slug: sirius-xm
646 + name: SiriusXM
647 + operator: sirius-xm
648 + country: US
649 + service: communications
650 + orbit: GEO
651 + patterns: ['^SIRIUS', '^XM-', '^SXM-']
652 + - slug: echostar
653 + name: EchoStar / Jupiter
654 + operator: echostar
655 + country: US
656 + service: communications
657 + orbit: GEO
658 + patterns: ['^ECHOSTAR', '^JUPITER \d', '^HUGHES']
659 + - slug: yaogan
660 + name: Yaogan
661 + operator: yaogan-program
662 + country: CN
663 + service: military
664 + orbit: LEO
665 + patterns: ['^YAOGAN']
666 + description: Chinese military remote-sensing series (official designation "Yaogan Weixing").
667 + - slug: jilin-1
668 + name: Jilin-1
669 + operator: china-changguang
670 + country: CN
671 + service: earth-observation
672 + orbit: LEO
673 + patterns: ['^JILIN']
674 + - slug: geesat
675 + name: Geely GEESAT
676 + operator: geespace
677 + country: CN
678 + service: iot
679 + orbit: LEO
680 + patterns: ['^GEESAT']
681 + - slug: tianqi
682 + name: Tianqi
683 + operator: guodian-gaoke
684 + country: CN
685 + service: iot
686 + orbit: LEO
687 + patterns: ['^TIANQI']
688 + - slug: gaofen
689 + name: Gaofen (CHEOS)
690 + operator: cnsa
691 + country: CN
692 + service: earth-observation
693 + orbit: MIXED
694 + patterns: ['^GAOFEN']
695 + - slug: shiyan
696 + name: Shiyan
697 + operator: casc
698 + country: CN
699 + service: technology
700 + orbit: MIXED
701 + patterns: ['^SHIYAN']
702 + - slug: shijian
703 + name: Shijian
704 + operator: casc
705 + country: CN
706 + service: technology
707 + orbit: MIXED
708 + patterns: ['^SHIJIAN']
709 + - slug: tjs
710 + name: TJS (Tongxin Jishu Shiyan)
711 + operator: pla-ssf
712 + country: CN
713 + service: military
714 + orbit: GEO
715 + patterns: ['^TJS']
716 + - slug: chinasat
717 + name: ChinaSat / Zhongxing
718 + operator: china-satcom
719 + country: CN
720 + service: communications
721 + orbit: GEO
722 + patterns: ['^CHINASAT', '^ZHONGXING', '^ZX-']
723 + - slug: fengyun
724 + name: Fengyun
725 + operator: cnsa
726 + country: CN
727 + service: weather
728 + orbit: MIXED
729 + patterns: ['^FENGYUN', '^FY-']
730 + - slug: cosmos
731 + name: Kosmos (Russian military / government)
732 + operator: russian-mod
733 + country: RU
734 + service: military
735 + orbit: MIXED
736 + patterns: ['^COSMOS \d+$', '^COSMOS \d+ \(']
737 + - slug: gonets
738 + name: Gonets
739 + operator: gonets
740 + country: RU
741 + service: communications
742 + orbit: LEO
743 + patterns: ['^GONETS']
744 + - slug: rassvet
745 + name: Rassvet (Bureau 1440)
746 + operator: sitro-ag
747 + country: RU
748 + service: communications
749 + orbit: LEO
750 + patterns: ['^RASSVET']
751 + - slug: sitro
752 + name: SITRO-AIS
753 + operator: sitro-ag
754 + country: RU
755 + service: iot
756 + orbit: LEO
757 + patterns: ['^SITRO']
758 + - slug: goes
759 + name: GOES
760 + operator: noaa
761 + country: US
762 + service: weather
763 + orbit: GEO
764 + celestrak_groups: [goes]
765 + patterns: ['^GOES']
766 + - slug: noaa-poes
767 + name: NOAA POES / JPSS
768 + operator: noaa
769 + country: US
770 + service: weather
771 + orbit: LEO
772 + celestrak_groups: [noaa]
773 + patterns: ['^NOAA \d', '^SUOMI NPP', '^JPSS']
774 + - slug: metop
775 + name: Metop / Meteosat
776 + operator: eumetsat
777 + country: null
778 + service: weather
779 + orbit: MIXED
780 + patterns: ['^METOP', '^METEOSAT', '^MTG-']
781 + - slug: sentinel
782 + name: Copernicus Sentinel
783 + operator: esa
784 + country: null
785 + service: earth-observation
786 + orbit: LEO
787 + patterns: ['^SENTINEL']
788 + - slug: landsat
789 + name: Landsat
790 + operator: nasa
791 + country: US
792 + service: earth-observation
793 + orbit: LEO
794 + patterns: ['^LANDSAT']
795 + - slug: worldview
796 + name: Maxar WorldView / Legion
797 + operator: maxar
798 + country: US
799 + service: earth-observation
800 + orbit: LEO
801 + patterns: ['^WORLDVIEW', '^GEOEYE', '^WV LEGION', '^WORLDVIEW LEGION']
802 + - slug: pleiades
803 + name: Pléiades / Pléiades Neo
804 + operator: airbus-defence-space
805 + country: FR
806 + service: earth-observation
807 + orbit: LEO
808 + patterns: ['^PLEIADES']
809 + - slug: cartosat
810 + name: Cartosat / RISAT / EOS
811 + operator: isro
812 + country: IN
813 + service: earth-observation
814 + orbit: LEO
815 + patterns: ['^CARTOSAT', '^RISAT', '^EOS-\d', '^RESOURCESAT', '^OCEANSAT']
816 + - slug: gsat
817 + name: GSAT / INSAT
818 + operator: isro
819 + country: IN
820 + service: communications
821 + orbit: GEO
822 + patterns: ['^GSAT-', '^INSAT', '^CMS-\d']
823 + - slug: himawari
824 + name: Himawari
825 + operator: jaxa
826 + country: JP
827 + service: weather
828 + orbit: GEO
829 + patterns: ['^HIMAWARI']
830 + - slug: radarsat
831 + name: RADARSAT
832 + operator: csa
833 + country: CA
834 + service: earth-observation
835 + orbit: LEO
836 + patterns: ['^RADARSAT', '^RCM-']
837 + - slug: iss
838 + name: International Space Station
839 + operator: nasa
840 + country: null
841 + service: station
842 + orbit: LEO
843 + celestrak_groups: [stations]
844 + patterns: ['^ISS \(', '^ISS DEB', '^CREW DRAGON', '^CYGNUS', '^PROGRESS-MS', '^SOYUZ-MS', '^DRAGON ', '^HTV', '^CSS \(']
845 + - slug: usa-military
846 + name: USA (classified US military payloads)
847 + operator: nro
848 + country: US
849 + service: military
850 + orbit: MIXED
851 + patterns: ['^USA \d+$', '^USA \d+ \(']
852 + - slug: lynk
853 + name: Lynk Global
854 + operator: lynk
855 + country: US
856 + service: communications
857 + orbit: LEO
858 + patterns: ['^LYNK']
859 + - slug: sateliot
860 + name: Sateliot
861 + operator: sateliot
862 + country: ES
863 + service: iot
864 + orbit: LEO
865 + patterns: ['^SATELIOT']
866 + - slug: myriota
867 + name: Myriota
868 + operator: myriota
869 + country: AU
870 + service: iot
871 + orbit: LEO
872 + patterns: ['^MYRIOTA']
873 + - slug: centispace
874 + name: CentiSpace
875 + operator: casc
876 + country: CN
877 + service: navigation
878 + orbit: LEO
879 + patterns: ['^CENTISPACE']
880 + - slug: tianmu
881 + name: Tianmu-1
882 + operator: casc
883 + country: CN
884 + service: weather
885 + orbit: LEO
886 + patterns: ['^TIANMU']
887 + - slug: iride
888 + name: IRIDE
889 + operator: dsi-italy
890 + country: IT
891 + service: earth-observation
892 + orbit: LEO
893 + patterns: ['^IRIDE']
894 + - slug: praetorian
895 + name: Praetorian (Firefly / military rideshare)
896 + operator: us-space-force
897 + country: US
898 + service: military
899 + orbit: LEO
900 + patterns: ['^PRAETORIAN']
901 + - slug: hongyun
902 + name: Hongyun / Yuanxin
903 + operator: china-yuanxin
904 + country: CN
905 + service: communications
906 + orbit: LEO
907 + patterns: ['^HONGYUN', '^YUANXIN']
908 + - slug: cubesat-other
909 + name: SBAS / augmentation
910 + operator: null
911 + country: null
912 + service: navigation
913 + orbit: GEO
914 + celestrak_groups: [sbas]
915 + patterns: []
916 +
917 +# Fallback mission type by name pattern when no constellation matched (derived, lowest confidence).
918 +mission_patterns:
919 + - { type: navigation, patterns: ['NAVSTAR', 'GLONASS', 'GALILEO', 'BEIDOU', 'QZS', 'IRNSS', 'GNSS'] }
920 + - { type: weather, patterns: ['^METEOR', '^NOAA', '^GOES', '^METEOSAT', '^FENGYUN', '^HIMAWARI', '^DMSP', '^ELEKTRO', '^ARKTIKA', '^INSAT-3D'] }
921 + - { type: earth-observation, patterns: ['^LANDSAT', '^SENTINEL', '^TERRA', '^AQUA', '^SPOT ', '^RADARSAT', '^COSMO-SKYMED', '^TERRASAR', '^TANDEM', '^ALOS', '^GAOFEN', '^ZIYUAN', '^HAIYANG', '^CBERS', '^KOMPSAT', '^PAZ ', '^EROS', '^OFEQ', '^CARTOSAT', '^RESOURCESAT', '^HUANJING', '^SUPERVIEW', '^SKYSAT', '^SAR'] }
922 + - { type: communications, patterns: ['COMSAT', 'TDRS', 'MUOS', 'WGS', 'AEHF', 'SKYNET', 'SYRACUSE', 'SICRAL', 'ASTRA', 'THAICOM', 'APSTAR', 'MEASAT', 'TURKSAT', 'ARABSAT', 'NILESAT', 'AMAZONAS', 'HISPASAT', 'YAMAL', 'EXPRESS', 'INTELSAT', 'EUTELSAT', 'BADR', 'NUSANTARA', 'TELKOM', 'OPTUS', 'SKY MUSTER', 'JCSAT', 'SUPERBIRD', 'BSAT', 'KOREASAT', 'CHINASAT', 'TIANLIAN', 'APSTAR', 'ABS-', 'YAHSAT', 'AL YAH', 'ES''HAIL', 'TUPAC', 'SES', 'AMC', 'GALAXY', 'ECHOSTAR', 'DIRECTV', 'VIASAT', 'JUPITER', 'INMARSAT', 'THURAYA'] }
923 + - { type: science, patterns: ['^HST', 'HUBBLE', '^CHANDRA', '^FERMI', '^SWIFT', '^NUSTAR', '^XMM', '^INTEGRAL', '^GAIA', '^TESS', '^KEPLER', '^JWST', '^SOHO', '^ACE ', '^WIND', '^THEMIS', '^MMS ', '^VAN ALLEN', '^CLUSTER', '^SWARM', '^GRACE', '^ICESAT', '^JASON', '^SENTINEL-6', '^CRYOSAT', '^SMOS', '^AEOLUS', '^EARTHCARE', '^SMAP', '^GPM', '^CALIPSO', '^CLOUDSAT', '^OCO', '^GOSAT', '^TANSAT', '^DSCOVR', '^PACE', '^SWOT', '^NISAR', '^XRISM', '^EINSTEIN PROBE', '^IXPE', '^SVOM', '^CSES', '^HXMT', '^DAMPE', '^QUESS', '^TAIJI', '^TIANWEN', '^CHANG'] }
924 + - { type: military, patterns: ['^USA \d', '^COSMOS', '^YAOGAN', '^OFEQ', '^HELIOS', '^SAR-LUPE', '^SARAH', '^CSO-', '^CERES', '^ESSAIM', '^ELISA', '^IGS ', '^KH-', '^NROL', '^TJS', '^SBIRS', '^DSP', '^GSSAP', '^MILSTAR', '^TOPAZ', '^LIANA', '^LOTOS', '^PION', '^BARS', '^PERSONA', '^TUNDRA', '^KONDOR', '^EMISAT', '^ANGELS'] }
925 + - { type: technology, patterns: ['^SHIYAN', '^SHIJIAN', '^TECHNOSAT', '^TET-', '^PROBA', '^OPS-SAT', '^ELSA-D', '^ADRAS', '^CLEARSPACE', '^DEMO', '^TEST', '^EXPERIMENT', '^TECH'] }
added src/satelliteindex/registry/owners.json +1 −0
@@ -0,0 +1 @@
1 +[["AB", "Arab Satellite Communications Organization"], ["ABS", "Asia Broadcast Satellite"], ["AC", "Asia Satellite Telecommunications Company (ASIASAT)"], ["ALG", "Algeria"], ["ANG", "Angola"], ["ARGN", "Argentina"], ["ARM", "Republic of Armenia"], ["ASRA", "Austria"], ["AUS", "Australia"], ["AZER", "Azerbaijan"], ["BEL", "Belgium"], ["BELA", "Belarus"], ["BERM", "Bermuda"], ["BGD", "Peoples Republic of Bangladesh"], ["BHR", "The Kingdom of Bahrain"], ["BHUT", "The Kingdom of Bhutan"], ["BOL", "Bolivia"], ["BRAZ", "Brazil"], ["BUL", "Bulgaria"], ["BWA", "Republic of Botswana"], ["CA", "Canada"], ["CHBZ", "China/Brazil"], ["CHTU", "China/T\u00fcrkiye"], ["CHLE", "Chile"], ["CIS", "Commonwealth of Independent States (former USSR)"], ["COL", "Colombia"], ["CRI", "Republic of Costa Rica"], ["CZCH", "Czech Republic (former Czechoslovakia)"], ["DEN", "Denmark"], ["DJI", "Republic of Djibouti"], ["ECU", "Ecuador"], ["EGYP", "Egypt"], ["ESA", "European Space Agency"], ["ESRO", "European Space Research Organization"], ["EST", "Estonia"], ["ETH", "Ethiopia"], ["EUME", "European Organization for theExploitation of Meteorological Satellites (EUMETSAT)"], ["EUTE", "European Telecommunications Satellite Organization (EUTELSAT)"], ["FGER", "France/Germany"], ["FIN", "Finland"], ["FR", "France"], ["FRIT", "France/Italy"], ["GER", "Germany"], ["GHA", "Republic of Ghana"], ["GLOB", "Globalstar"], ["GREC", "Greece"], ["GRSA", "Greece/Saudi Arabia"], ["GUAT", "Guatemala"], ["HRV", "Republic of Croatia"], ["HUN", "Hungary"], ["IM", "International Mobile Satellite Organization (INMARSAT)"], ["IND", "India"], ["INDO", "Indonesia"], ["IRAN", "Iran"], ["IRAQ", "Iraq"], ["IRID", "Iridium"], ["IRL", "Ireland"], ["ISRA", "Israel"], ["ISRO", "Indian Space Research Organisation"], ["ISS", "International Space Station"], ["IT", "Italy"], ["ITSO", "International Telecommunications Satellite Organization (INTELSAT)"], ["JPN", "Japan"], ["KAZ", "Kazakhstan"], ["KEN", "Republic of Kenya"], ["LAOS", "Laos"], ["LKA", "Democratic Socialist Republic of Sri Lanka"], ["LTU", "Lithuania"], ["LUXE", "Luxembourg"], ["MA", "Morroco"], ["MALA", "Malaysia"], ["MCO", "Principality of Monaco"], ["MDA", "Republic of Moldova"], ["MEX", "Mexico"], ["MMR", "Republic of the Union of Myanmar"], ["MNE", "Montenegro"], ["MNG", "Mongolia"], ["MUS", "Mauritius"], ["NATO", "North Atlantic Treaty Organization"], ["NETH", "Netherlands"], ["NICO", "New ICO"], ["NIG", "Nigeria"], ["NKOR", "Democratic People's Republic of Korea"], ["NOR", "Norway"], ["NPL", "Federal Democratic Republic of Nepal"], ["NZ", "New Zealand"], ["O3B", "O3b Networks"], ["ORB", "ORBCOMM"], ["PAKI", "Pakistan"], ["PERU", "Peru"], ["POL", "Poland"], ["POR", "Portugal"], ["PRC", "People's Republic of China"], ["PRY", "Republic of Paraguay"], ["PRES", "People's Republic of China/European Space Agency"], ["QAT", "State of Qatar"], ["RASC", "RascomStar-QAF"], ["ROC", "Taiwan (Republic of China)"], ["ROM", "Romania"], ["RP", "Philippines (Republic of the Philippines)"], ["RWA", "Republic of Rwanda"], ["SAFR", "South Africa"], ["SAUD", "Saudi Arabia"], ["SDN", "Republic of Sudan"], ["SEAL", "Sea Launch"], ["SEN", "Republic of Senegal"], ["SES", "SES"], ["SGJP", "Singapore/Japan"], ["SING", "Singapore"], ["SKOR", "Republic of Korea"], ["SLB", "Solomon Islands"], ["SPN", "Spain"], ["STCT", "Singapore/Taiwan"], ["SVN", "Slovenia"], ["SWED", "Sweden"], ["SWTZ", "Switzerland"], ["TBD", "To Be Determined"], ["THAI", "Thailand"], ["TMMC", "Turkmenistan/Monaco"], ["TUN", "Republic of Tunisia"], ["TURK", "T\u00fcrkiye"], ["UAE", "United Arab Emirates"], ["UK", "United Kingdom"], ["UKR", "Ukraine"], ["UNK", "Unknown"], ["URY", "Uruguay"], ["US", "United States"], ["USBZ", "United States/Brazil"], ["VAT", "Vatican City State"], ["VENZ", "Venezuela"], ["VTNM", "Vietnam"], ["ZWE", "Republic of Zimbabwe"]]
\ No newline at end of file
added src/satelliteindex/registry/reference.py +142 −0
@@ -0,0 +1,142 @@
1 +"""Reference data: ISO countries used by the catalog, SATCAT owner codes → country/organization, launch sites.
2 +
3 +Owner and launch-site code lists come from CelesTrak (https://celestrak.org/satcat/sources.php,
4 +https://celestrak.org/satcat/launchsites.php); ISO mapping and coordinates are curated here (see docs/METHODOLOGY.md).
5 +"""
6 +from __future__ import annotations
7 +
8 +# code, iso3, name, region
9 +COUNTRIES: list[tuple[str, str, str, str]] = [
10 + ("US", "USA", "United States", "North America"), ("RU", "RUS", "Russia", "Europe"), ("CN", "CHN", "China", "Asia"),
11 + ("FR", "FRA", "France", "Europe"), ("JP", "JPN", "Japan", "Asia"), ("IN", "IND", "India", "Asia"), ("GB", "GBR", "United Kingdom", "Europe"),
12 + ("DE", "DEU", "Germany", "Europe"), ("IT", "ITA", "Italy", "Europe"), ("CA", "CAN", "Canada", "North America"), ("ES", "ESP", "Spain", "Europe"),
13 + ("BR", "BRA", "Brazil", "South America"), ("KR", "KOR", "South Korea", "Asia"), ("AU", "AUS", "Australia", "Oceania"), ("AR", "ARG", "Argentina", "South America"),
14 + ("IL", "ISR", "Israel", "Asia"), ("TR", "TUR", "Türkiye", "Asia"), ("TW", "TWN", "Taiwan", "Asia"), ("FI", "FIN", "Finland", "Europe"),
15 + ("IR", "IRN", "Iran", "Asia"), ("AE", "ARE", "United Arab Emirates", "Asia"), ("NO", "NOR", "Norway", "Europe"), ("PL", "POL", "Poland", "Europe"),
16 + ("SG", "SGP", "Singapore", "Asia"), ("NZ", "NZL", "New Zealand", "Oceania"), ("LU", "LUX", "Luxembourg", "Europe"), ("CH", "CHE", "Switzerland", "Europe"),
17 + ("ID", "IDN", "Indonesia", "Asia"), ("GR", "GRC", "Greece", "Europe"), ("SA", "SAU", "Saudi Arabia", "Asia"), ("NL", "NLD", "Netherlands", "Europe"),
18 + ("DK", "DNK", "Denmark", "Europe"), ("BE", "BEL", "Belgium", "Europe"), ("TH", "THA", "Thailand", "Asia"), ("CZ", "CZE", "Czech Republic", "Europe"),
19 + ("EG", "EGY", "Egypt", "Africa"), ("SE", "SWE", "Sweden", "Europe"), ("MY", "MYS", "Malaysia", "Asia"), ("RW", "RWA", "Rwanda", "Africa"),
20 + ("PK", "PAK", "Pakistan", "Asia"), ("MX", "MEX", "Mexico", "North America"), ("LT", "LTU", "Lithuania", "Europe"), ("PT", "PRT", "Portugal", "Europe"),
21 + ("ZA", "ZAF", "South Africa", "Africa"), ("HU", "HUN", "Hungary", "Europe"), ("UA", "UKR", "Ukraine", "Europe"), ("PH", "PHL", "Philippines", "Asia"),
22 + ("BG", "BGR", "Bulgaria", "Europe"), ("KZ", "KAZ", "Kazakhstan", "Asia"), ("KP", "PRK", "North Korea", "Asia"), ("CL", "CHL", "Chile", "South America"),
23 + ("DZ", "DZA", "Algeria", "Africa"), ("VN", "VNM", "Vietnam", "Asia"), ("NG", "NGA", "Nigeria", "Africa"), ("BY", "BLR", "Belarus", "Europe"),
24 + ("SK", "SVK", "Slovakia", "Europe"), ("PE", "PER", "Peru", "South America"), ("MA", "MAR", "Morocco", "Africa"), ("AZ", "AZE", "Azerbaijan", "Asia"),
25 + ("AT", "AUT", "Austria", "Europe"), ("VE", "VEN", "Venezuela", "South America"), ("SI", "SVN", "Slovenia", "Europe"), ("KW", "KWT", "Kuwait", "Asia"),
26 + ("CO", "COL", "Colombia", "South America"), ("ZW", "ZWE", "Zimbabwe", "Africa"), ("RO", "ROU", "Romania", "Europe"), ("KE", "KEN", "Kenya", "Africa"),
27 + ("EE", "EST", "Estonia", "Europe"), ("EC", "ECU", "Ecuador", "South America"), ("DJ", "DJI", "Djibouti", "Africa"), ("BD", "BGD", "Bangladesh", "Asia"),
28 + ("AO", "AGO", "Angola", "Africa"), ("VA", "VAT", "Vatican City", "Europe"), ("UY", "URY", "Uruguay", "South America"), ("UG", "UGA", "Uganda", "Africa"),
29 + ("TN", "TUN", "Tunisia", "Africa"), ("TM", "TKM", "Turkmenistan", "Asia"), ("SB", "SLB", "Solomon Islands", "Oceania"), ("SN", "SEN", "Senegal", "Africa"),
30 + ("SD", "SDN", "Sudan", "Africa"), ("QA", "QAT", "Qatar", "Asia"), ("PY", "PRY", "Paraguay", "South America"), ("NP", "NPL", "Nepal", "Asia"),
31 + ("MU", "MUS", "Mauritius", "Africa"), ("MN", "MNG", "Mongolia", "Asia"), ("ME", "MNE", "Montenegro", "Europe"), ("MM", "MMR", "Myanmar", "Asia"),
32 + ("MD", "MDA", "Moldova", "Europe"), ("MC", "MCO", "Monaco", "Europe"), ("LK", "LKA", "Sri Lanka", "Asia"), ("LA", "LAO", "Laos", "Asia"),
33 + ("JO", "JOR", "Jordan", "Asia"), ("IE", "IRL", "Ireland", "Europe"), ("IQ", "IRQ", "Iraq", "Asia"), ("HR", "HRV", "Croatia", "Europe"),
34 + ("GT", "GTM", "Guatemala", "North America"), ("GH", "GHA", "Ghana", "Africa"), ("ET", "ETH", "Ethiopia", "Africa"), ("CR", "CRI", "Costa Rica", "North America"),
35 + ("BW", "BWA", "Botswana", "Africa"), ("BO", "BOL", "Bolivia", "South America"), ("BT", "BTN", "Bhutan", "Asia"), ("BH", "BHR", "Bahrain", "Asia"),
36 + ("AM", "ARM", "Armenia", "Asia"), ("BM", "BMU", "Bermuda", "North America"),
37 +]
38 +
39 +# SATCAT owner code → (name, kind, iso2 or None). Multi-state codes keep kind 'joint' with the lead state.
40 +OWNER_CODES: dict[str, tuple[str, str, str | None]] = {
41 + "US": ("United States", "country", "US"), "CIS": ("Commonwealth of Independent States (former USSR)", "country", "RU"),
42 + "PRC": ("People's Republic of China", "country", "CN"), "FR": ("France", "country", "FR"), "JPN": ("Japan", "country", "JP"),
43 + "IND": ("India", "country", "IN"), "UK": ("United Kingdom", "country", "GB"), "GER": ("Germany", "country", "DE"), "IT": ("Italy", "country", "IT"),
44 + "CA": ("Canada", "country", "CA"), "SPN": ("Spain", "country", "ES"), "BRAZ": ("Brazil", "country", "BR"), "SKOR": ("Republic of Korea", "country", "KR"),
45 + "AUS": ("Australia", "country", "AU"), "ARGN": ("Argentina", "country", "AR"), "ISRA": ("Israel", "country", "IL"), "TURK": ("Türkiye", "country", "TR"),
46 + "ROC": ("Taiwan", "country", "TW"), "FIN": ("Finland", "country", "FI"), "IRAN": ("Iran", "country", "IR"), "UAE": ("United Arab Emirates", "country", "AE"),
47 + "NOR": ("Norway", "country", "NO"), "POL": ("Poland", "country", "PL"), "SING": ("Singapore", "country", "SG"), "NZ": ("New Zealand", "country", "NZ"),
48 + "LUXE": ("Luxembourg", "country", "LU"), "SWTZ": ("Switzerland", "country", "CH"), "INDO": ("Indonesia", "country", "ID"), "GREC": ("Greece", "country", "GR"),
49 + "SAUD": ("Saudi Arabia", "country", "SA"), "NETH": ("Netherlands", "country", "NL"), "DEN": ("Denmark", "country", "DK"), "BEL": ("Belgium", "country", "BE"),
50 + "THAI": ("Thailand", "country", "TH"), "CZCH": ("Czech Republic", "country", "CZ"), "EGYP": ("Egypt", "country", "EG"), "SWED": ("Sweden", "country", "SE"),
51 + "MALA": ("Malaysia", "country", "MY"), "RWA": ("Rwanda", "country", "RW"), "PAKI": ("Pakistan", "country", "PK"), "MEX": ("Mexico", "country", "MX"),
52 + "LTU": ("Lithuania", "country", "LT"), "POR": ("Portugal", "country", "PT"), "SAFR": ("South Africa", "country", "ZA"), "HUN": ("Hungary", "country", "HU"),
53 + "UKR": ("Ukraine", "country", "UA"), "RP": ("Philippines", "country", "PH"), "BUL": ("Bulgaria", "country", "BG"), "KAZ": ("Kazakhstan", "country", "KZ"),
54 + "NKOR": ("North Korea", "country", "KP"), "CHLE": ("Chile", "country", "CL"), "ALG": ("Algeria", "country", "DZ"), "VTNM": ("Vietnam", "country", "VN"),
55 + "NIG": ("Nigeria", "country", "NG"), "BELA": ("Belarus", "country", "BY"), "SVK": ("Slovakia", "country", "SK"), "PERU": ("Peru", "country", "PE"),
56 + "MA": ("Morocco", "country", "MA"), "AZER": ("Azerbaijan", "country", "AZ"), "ASRA": ("Austria", "country", "AT"), "VENZ": ("Venezuela", "country", "VE"),
57 + "SVN": ("Slovenia", "country", "SI"), "KWT": ("Kuwait", "country", "KW"), "COL": ("Colombia", "country", "CO"), "ZWE": ("Zimbabwe", "country", "ZW"),
58 + "ROM": ("Romania", "country", "RO"), "KEN": ("Kenya", "country", "KE"), "EST": ("Estonia", "country", "EE"), "ECU": ("Ecuador", "country", "EC"),
59 + "DJI": ("Djibouti", "country", "DJ"), "BGD": ("Bangladesh", "country", "BD"), "ANG": ("Angola", "country", "AO"), "VAT": ("Vatican City", "country", "VA"),
60 + "URY": ("Uruguay", "country", "UY"), "UGA": ("Uganda", "country", "UG"), "TUN": ("Tunisia", "country", "TN"), "SLB": ("Solomon Islands", "country", "SB"),
61 + "SEN": ("Senegal", "country", "SN"), "SDN": ("Sudan", "country", "SD"), "QAT": ("Qatar", "country", "QA"), "PRY": ("Paraguay", "country", "PY"),
62 + "NPL": ("Nepal", "country", "NP"), "MUS": ("Mauritius", "country", "MU"), "MNG": ("Mongolia", "country", "MN"), "MNE": ("Montenegro", "country", "ME"),
63 + "MMR": ("Myanmar", "country", "MM"), "MDA": ("Moldova", "country", "MD"), "MCO": ("Monaco", "country", "MC"), "LKA": ("Sri Lanka", "country", "LK"),
64 + "LAOS": ("Laos", "country", "LA"), "JOR": ("Jordan", "country", "JO"), "IRL": ("Ireland", "country", "IE"), "IRAQ": ("Iraq", "country", "IQ"),
65 + "HRV": ("Croatia", "country", "HR"), "GUAT": ("Guatemala", "country", "GT"), "GHA": ("Ghana", "country", "GH"), "ETH": ("Ethiopia", "country", "ET"),
66 + "CRI": ("Costa Rica", "country", "CR"), "BWA": ("Botswana", "country", "BW"), "BOL": ("Bolivia", "country", "BO"), "BHUT": ("Bhutan", "country", "BT"),
67 + "BHR": ("Bahrain", "country", "BH"), "ARM": ("Armenia", "country", "AM"), "BERM": ("Bermuda", "country", "BM"),
68 + # joint programmes (lead state first)
69 + "CHBZ": ("China/Brazil", "joint", "CN"), "CHTU": ("China/Türkiye", "joint", "CN"), "FGER": ("France/Germany", "joint", "FR"),
70 + "FRIT": ("France/Italy", "joint", "FR"), "GRSA": ("Greece/Saudi Arabia", "joint", "GR"), "PRES": ("China/European Space Agency", "joint", "CN"),
71 + "SGJP": ("Singapore/Japan", "joint", "SG"), "STCT": ("Singapore/Taiwan", "joint", "SG"), "TMMC": ("Turkmenistan/Monaco", "joint", "TM"),
72 + "USBZ": ("United States/Brazil", "joint", "US"),
73 + # intergovernmental / international organizations and commercial consortia
74 + "ESA": ("European Space Agency", "intergovernmental", None), "ESRO": ("European Space Research Organization", "intergovernmental", None),
75 + "EUME": ("EUMETSAT", "intergovernmental", None), "EUTE": ("Eutelsat", "organization", "FR"), "ITSO": ("Intelsat", "organization", "US"),
76 + "IM": ("Inmarsat", "organization", "GB"), "ISS": ("International Space Station", "intergovernmental", None), "NATO": ("NATO", "intergovernmental", None),
77 + "AB": ("Arabsat", "intergovernmental", "SA"), "ABS": ("Asia Broadcast Satellite", "organization", "BM"), "AC": ("AsiaSat", "organization", "CN"),
78 + "SES": ("SES", "organization", "LU"), "O3B": ("O3b Networks (SES)", "organization", "LU"), "ORB": ("ORBCOMM", "organization", "US"),
79 + "GLOB": ("Globalstar", "organization", "US"), "IRID": ("Iridium", "organization", "US"), "NICO": ("New ICO", "organization", "GB"),
80 + "RASC": ("RascomStar-QAF", "organization", "MU"), "SEAL": ("Sea Launch", "organization", None), "ISRO": ("Indian Space Research Organisation", "agency", "IN"),
81 + "TBD": ("To be determined", "unknown", None), "UNK": ("Unknown", "unknown", None),
82 +}
83 +
84 +# SATCAT launch site code → (name, iso2, lat, lon)
85 +LAUNCH_SITES: dict[str, tuple[str, str | None, float | None, float | None]] = {
86 + "AFETR": ("Cape Canaveral / Kennedy Space Center (Eastern Range), Florida", "US", 28.49, -80.57),
87 + "AFWTR": ("Vandenberg Space Force Base (Western Range), California", "US", 34.74, -120.57),
88 + "ANDSP": ("Andøya Spaceport, Nordland", "NO", 69.29, 16.02),
89 + "ALCLC": ("Alcântara Launch Center, Maranhão", "BR", -2.37, -44.40),
90 + "BOS": ("Bowen Orbital Spaceport, Queensland", "AU", -20.01, 148.24),
91 + "CAS": ("Canaries Airspace (air launch)", "ES", 28.0, -16.0),
92 + "DLS": ("Dombarovskiy (Yasny) Launch Site", "RU", 51.09, 59.85),
93 + "ERAS": ("Eastern Range Airspace (air launch)", "US", 28.5, -78.0),
94 + "FRGUI": ("Guiana Space Centre, Kourou", "FR", 5.24, -52.77),
95 + "HGSTR": ("Hammaguir Space Track Range", "DZ", 30.88, -3.07),
96 + "JJSLA": ("Jeju Island Sea Launch Area", "KR", 33.0, 126.5),
97 + "JSC": ("Jiuquan Satellite Launch Center", "CN", 40.96, 100.29),
98 + "KODAK": ("Pacific Spaceport Complex, Kodiak, Alaska", "US", 57.44, -152.34),
99 + "KSCUT": ("Uchinoura Space Center", "JP", 31.25, 131.08),
100 + "KWAJ": ("Kwajalein Atoll (Reagan Test Site)", "US", 9.05, 167.74),
101 + "KYMSC": ("Kapustin Yar Missile and Space Complex", "RU", 48.58, 46.25),
102 + "NSC": ("Naro Space Center", "KR", 34.43, 127.54),
103 + "PLMSC": ("Plesetsk Cosmodrome", "RU", 62.93, 40.57),
104 + "RLLB": ("Rocket Lab Launch Complex 1, Mahia", "NZ", -39.26, 177.86),
105 + "SCSLA": ("South China Sea Launch Area (sea launch)", "CN", 19.6, 111.0),
106 + "SEAL": ("Sea Launch Odyssey platform (Pacific, equator)", None, 0.0, -154.0),
107 + "SEMLS": ("Semnan (Imam Khomeini) Spaceport", "IR", 35.23, 53.92),
108 + "SMTS": ("Shahrud Missile Test Site", "IR", 36.20, 55.33),
109 + "SNMLP": ("San Marco platform, Formosa Bay", "KE", -2.94, 40.21),
110 + "SPKII": ("Space Port Kii, Wakayama", "JP", 33.54, 135.89),
111 + "SRILR": ("Satish Dhawan Space Centre, Sriharikota", "IN", 13.72, 80.23),
112 + "SUBL": ("Submarine launch platform (Barents Sea)", "RU", 69.5, 34.0),
113 + "SVOBO": ("Svobodny Cosmodrome", "RU", 51.70, 128.00),
114 + "TAISC": ("Taiyuan Satellite Launch Center", "CN", 38.85, 111.61),
115 + "TANSC": ("Tanegashima Space Center", "JP", 30.40, 130.97),
116 + "TYMSC": ("Baikonur Cosmodrome (Tyuratam)", "KZ", 45.92, 63.34),
117 + "UNK": ("Unknown", None, None, None),
118 + "VOSTO": ("Vostochny Cosmodrome", "RU", 51.88, 128.33),
119 + "WLPIS": ("Wallops Flight Facility / MARS, Virginia", "US", 37.84, -75.49),
120 + "WOMRA": ("Woomera Range Complex", "AU", -30.95, 136.53),
121 + "WRAS": ("Western Range Airspace (air launch)", "US", 34.0, -122.0),
122 + "WSC": ("Wenchang Space Launch Site, Hainan", "CN", 19.61, 110.95),
123 + "XICLF": ("Xichang Satellite Launch Center", "CN", 28.25, 102.03),
124 + "YAVNE": ("Palmachim Airbase, Yavne", "IL", 31.88, 34.68),
125 + "YSLA": ("Yellow Sea Launch Area (sea launch)", "CN", 34.9, 121.2),
126 + "YUN": ("Sohae Satellite Launching Station", "KP", 39.66, 124.71),
127 +}
128 +
129 +SATCAT_OBJECT_TYPES = {"PAY": "PAYLOAD", "R/B": "ROCKET_BODY", "DEB": "DEBRIS", "UNK": "UNKNOWN"}
130 +
131 +# SATCAT operational status codes → canonical status.
132 +# https://celestrak.org/satcat/status.php
133 +SATCAT_STATUS = {
134 + "+": "ACTIVE", # operational
135 + "P": "ACTIVE", # partially operational
136 + "B": "INACTIVE", # backup / standby
137 + "S": "INACTIVE", # spare
138 + "X": "INACTIVE", # extended mission
139 + "D": "DECAYED",
140 + "?": "UNKNOWN",
141 + "": "UNKNOWN",
142 +}
added src/satelliteindex/registry/seed.py +184 −0
@@ -0,0 +1,184 @@
1 +"""Idempotent seeding of sources, connectors, reference tables, organizations and constellations."""
2 +from __future__ import annotations
3 +
4 +import logging
5 +
6 +from sqlalchemy.ext.asyncio import AsyncConnection
7 +
8 +from satelliteindex import registry
9 +from satelliteindex.db import execute, execute_many, fetch_all
10 +from satelliteindex.ids import new_id, normalize_name, slugify
11 +from satelliteindex.registry.reference import COUNTRIES, LAUNCH_SITES, OWNER_CODES
12 +
13 +log = logging.getLogger(__name__)
14 +
15 +SOURCES = [
16 + {"id": "celestrak", "name": "CelesTrak", "type": "orbital", "base_url": "https://celestrak.org", "official": False,
17 + "country_code": "US", "authority_type": "scientific", "license": "Free for non-commercial and commercial use with attribution (CelesTrak terms)",
18 + "attribution_required": True, "attribution_text": "Orbital data courtesy of CelesTrak (Dr. T.S. Kelso).", "update_frequency_seconds": 7200, "priority": 100},
19 + {"id": "celestrak_satcat", "name": "CelesTrak SATCAT", "type": "catalog", "base_url": "https://celestrak.org/satcat/", "official": False,
20 + "country_code": "US", "authority_type": "scientific", "license": "CelesTrak terms", "attribution_required": True,
21 + "attribution_text": "Satellite catalog data courtesy of CelesTrak.", "update_frequency_seconds": 86400, "priority": 90},
22 + {"id": "satelliteindex", "name": "SatelliteIndex (derived / curated)", "type": "derived", "base_url": "https://www.satelliteindex.io/methodology",
23 + "official": False, "country_code": "CA", "authority_type": "secondary", "license": "SatelliteIndex methodology", "attribution_required": False,
24 + "attribution_text": "Derived by SatelliteIndex (name patterns, orbit classification, curated registry).", "update_frequency_seconds": None, "priority": 10},
25 + {"id": "space_track", "name": "Space-Track (18 SDS)", "type": "orbital", "base_url": "https://www.space-track.org", "official": True,
26 + "country_code": "US", "authority_type": "government", "license": "Space-Track user agreement", "attribution_required": True,
27 + "attribution_text": "Space-Track.org (18th Space Defense Squadron).", "update_frequency_seconds": 14400, "priority": 95, "enabled": False},
28 + {"id": "gcat", "name": "GCAT (Jonathan McDowell)", "type": "catalog", "base_url": "https://planet4589.org/space/gcat/", "official": False,
29 + "country_code": "US", "authority_type": "scientific", "license": "CC BY 4.0", "attribution_required": True,
30 + "attribution_text": "GCAT: General Catalog of Artificial Space Objects, Jonathan C. McDowell (CC BY 4.0).", "update_frequency_seconds": 86400, "priority": 80, "enabled": False},
31 + {"id": "unoosa", "name": "UNOOSA Register of Objects Launched into Outer Space", "type": "registry", "base_url": "https://www.unoosa.org/oosa/osoindex/", "official": True,
32 + "country_code": None, "authority_type": "intergovernmental", "license": "UN terms of use", "attribution_required": True,
33 + "attribution_text": "United Nations Office for Outer Space Affairs.", "update_frequency_seconds": 86400, "priority": 85, "enabled": False},
34 + {"id": "esa_discos", "name": "ESA DISCOS", "type": "catalog", "base_url": "https://discosweb.esoc.esa.int", "official": True,
35 + "country_code": None, "authority_type": "intergovernmental", "license": "ESA DISCOS terms (API key)", "attribution_required": True,
36 + "attribution_text": "ESA DISCOS (Database and Information System Characterising Objects in Space).", "update_frequency_seconds": 86400, "priority": 85, "enabled": False},
37 +]
38 +
39 +CONNECTORS = [
40 + {"name": "celestrak_gp", "source_id": "celestrak", "description": "CelesTrak GP element sets (OMM JSON) for the `active` group + orbital history",
41 + "interval_seconds": 7200, "priority": 100, "config": {"groups": ["active"]}},
42 + {"name": "celestrak_groups", "source_id": "celestrak", "description": "CelesTrak thematic GP groups → satellite tags / constellation membership",
43 + "interval_seconds": 7200, "priority": 90,
44 + "config": {"groups": ["stations", "starlink", "oneweb", "kuiper", "qianfan", "hulianwang", "planet", "spire", "iridium-NEXT", "orbcomm",
45 + "globalstar", "swarm", "weather", "noaa", "goes", "resource", "sarsat", "dmc", "tdrss", "argos", "geo", "intelsat", "ses",
46 + "eutelsat", "telesat", "gnss", "gps-ops", "glo-ops", "galileo", "beidou", "sbas", "nnss", "musson", "science", "geodetic",
47 + "engineering", "education", "military", "radar", "cubesat", "x-comm", "other-comm", "amateur", "satnogs", "visual", "analyst", "last-30-days"]}},
48 + {"name": "celestrak_satcat", "source_id": "celestrak_satcat", "description": "CelesTrak SATCAT (all catalogued objects: type, owner, launch, decay, status)",
49 + "interval_seconds": 86400, "priority": 95, "config": {"url": "/pub/satcat.csv"}},
50 + {"name": "derived_analytics", "source_id": "satelliteindex", "description": "Refresh materialized views, stats snapshots, search index and derived events",
51 + "interval_seconds": 3600, "priority": 50, "config": {}},
52 +]
53 +
54 +METRICS = [
55 + {"key": "orbit_class", "name": "Orbit class", "version": "1.0",
56 + "methodology": "Derived from the latest element set: GEO = period 1436±30 min, e<0.05, i<20°; HEO = e>0.25 and apogee>35 000 km; LEO = apogee<2 000 km; MEO = 2 000 km≤perigee and apogee<35 586+2 000 km; else OTHER.",
57 + "inputs": ["orbital_state.period_minutes", "orbital_state.eccentricity", "orbital_state.inclination", "orbital_state.apogee_km", "orbital_state.perigee_km"]},
58 + {"key": "status", "name": "Object status", "version": "1.0",
59 + "methodology": "SATCAT operational status code (+/P → ACTIVE, B/S/X → INACTIVE, D or decay date → DECAYED, ? → UNKNOWN). Objects without a SATCAT code but present in the CelesTrak `active` GP group are ACTIVE; debris and rocket bodies still on orbit are INACTIVE.",
60 + "inputs": ["satcat.OPS_STATUS_CODE", "satcat.DECAY_DATE", "celestrak.active"]},
61 + {"key": "constellation_membership", "name": "Constellation membership", "version": "1.0",
62 + "methodology": "Membership in a CelesTrak GP group mapped to a constellation first (source-backed); otherwise a documented regular expression on the object name (derived). Membership history is kept in constellation_memberships.",
63 + "inputs": ["celestrak groups", "registry/constellations.yaml"]},
64 + {"key": "orbital_density", "name": "Orbital density by altitude bucket", "version": "1.0",
65 + "methodology": "Count of on-orbit Earth-centred objects per perigee bucket (0–200 … 1000–2000 km, MEO, GEO, HEO). Informational only — not a collision-risk metric.",
66 + "inputs": ["satellites.perigee_km", "satellites.orbit_class"]},
67 + {"key": "freshness", "name": "Data freshness", "version": "1.0",
68 + "methodology": "fresh < 12 h, aging 12–48 h, stale > 48 h since the source's last successful sync (orbital); catalog thresholds are 36 h / 96 h.",
69 + "inputs": ["connectors.last_success_at"]},
70 + {"key": "activity_score", "name": "SatelliteIndex Activity Score", "version": "0.1",
71 + "methodology": "Experimental. 100·(launched_last_30d/active) + 10·(launched_last_365d/active), capped at 100. Measures how fast a constellation is changing; not a safety or quality metric.",
72 + "inputs": ["constellation_stats.launched_last_30d", "constellation_stats.launched_last_365d", "constellation_stats.active"]},
73 +]
74 +
75 +
76 +async def seed(conn: AsyncConnection) -> dict[str, int]:
77 + counts: dict[str, int] = {}
78 + for s in SOURCES:
79 + s = {"enabled": True, **s}
80 + await execute(conn, """
81 + insert into sources (id, name, type, base_url, official, country_code, authority_type, license, attribution_required, attribution_text,
82 + update_frequency_seconds, enabled, priority)
83 + values (:id, :name, :type, :base_url, :official, :country_code, :authority_type, :license, :attribution_required, :attribution_text,
84 + :update_frequency_seconds, :enabled, :priority)
85 + on conflict (id) do update set name = excluded.name, type = excluded.type, base_url = excluded.base_url, official = excluded.official,
86 + authority_type = excluded.authority_type, license = excluded.license, attribution_required = excluded.attribution_required,
87 + attribution_text = excluded.attribution_text, update_frequency_seconds = excluded.update_frequency_seconds, priority = excluded.priority,
88 + updated_at = now()""", **s)
89 + counts["sources"] = len(SOURCES)
90 +
91 + import json
92 + for c in CONNECTORS:
93 + await execute(conn, """
94 + insert into connectors (name, source_id, description, interval_seconds, priority, config)
95 + values (:name, :source_id, :description, :interval_seconds, :priority, cast(:config as jsonb))
96 + on conflict (name) do update set source_id = excluded.source_id, description = excluded.description,
97 + interval_seconds = excluded.interval_seconds, priority = excluded.priority, config = excluded.config, updated_at = now()""",
98 + name=c["name"], source_id=c["source_id"], description=c["description"], interval_seconds=c["interval_seconds"],
99 + priority=c["priority"], config=json.dumps(c["config"]))
100 + counts["connectors"] = len(CONNECTORS)
101 +
102 + for m in METRICS:
103 + await execute(conn, """
104 + insert into metric_definitions (key, name, version, methodology, inputs) values (:key, :name, :version, :methodology, cast(:inputs as jsonb))
105 + on conflict (key) do update set name = excluded.name, version = excluded.version, methodology = excluded.methodology, inputs = excluded.inputs, updated_at = now()""",
106 + key=m["key"], name=m["name"], version=m["version"], methodology=m["methodology"], inputs=json.dumps(m["inputs"]))
107 + counts["metrics"] = len(METRICS)
108 +
109 + await execute_many(conn, """
110 + insert into countries (code, iso3, name, slug, region) values (:code, :iso3, :name, :slug, :region)
111 + on conflict (code) do update set iso3 = excluded.iso3, name = excluded.name, slug = excluded.slug, region = excluded.region""",
112 + [{"code": c, "iso3": i3, "name": n, "slug": slugify(n), "region": r} for c, i3, n, r in COUNTRIES])
113 + counts["countries"] = len(COUNTRIES)
114 +
115 + await execute_many(conn, """
116 + insert into launch_sites (code, name, slug, country_code, latitude, longitude) values (:code, :name, :slug, :country_code, :latitude, :longitude)
117 + on conflict (code) do update set name = excluded.name, slug = excluded.slug, country_code = excluded.country_code, latitude = excluded.latitude, longitude = excluded.longitude""",
118 + [{"code": code, "name": n, "slug": slugify(n), "country_code": cc, "latitude": lat, "longitude": lon}
119 + for code, (n, cc, lat, lon) in LAUNCH_SITES.items()])
120 + counts["launch_sites"] = len(LAUNCH_SITES)
121 +
122 + # organizations from the curated registry
123 + existing = {r["slug"]: r["id"] for r in await fetch_all(conn, "select slug, id from organizations")}
124 + for o in registry.organizations():
125 + oid = existing.get(o.slug) or new_id("organization")
126 + await execute(conn, """
127 + insert into organizations (id, slug, name, normalized_name, kind, country_code, official_url)
128 + values (:id, :slug, :name, :normalized_name, :kind, :country_code, :official_url)
129 + on conflict (slug) do update set name = excluded.name, normalized_name = excluded.normalized_name, kind = excluded.kind,
130 + country_code = excluded.country_code, official_url = coalesce(excluded.official_url, organizations.official_url), updated_at = now()""",
131 + id=oid, slug=o.slug, name=o.name, normalized_name=normalize_name(o.name), kind=o.kind, country_code=o.country, official_url=o.url)
132 + existing[o.slug] = oid
133 + aliases = {normalize_name(a): a for a in (o.name, *o.aliases)}
134 + await execute_many(conn, """
135 + insert into organization_aliases (organization_id, alias, normalized, source_id) values (:oid, :alias, :normalized, 'satelliteindex')
136 + on conflict (organization_id, normalized) do nothing""",
137 + [{"oid": oid, "alias": a, "normalized": n} for n, a in aliases.items()])
138 + counts["organizations"] = len(registry.organizations())
139 +
140 + # owner codes → organizations where the owner *is* an organization (SES, Iridium…)
141 + for code, (name, kind, cc) in OWNER_CODES.items():
142 + org_id = None
143 + if kind in ("organization", "agency", "intergovernmental"):
144 + norm = normalize_name(name)
145 + row = await fetch_all(conn, "select organization_id from organization_aliases where normalized = :n limit 1", n=norm)
146 + if row:
147 + org_id = row[0]["organization_id"]
148 + else:
149 + # create a lightweight organization for the owner code
150 + slug = slugify(name)
151 + oid = existing.get(slug) or new_id("organization")
152 + await execute(conn, """
153 + insert into organizations (id, slug, name, normalized_name, kind, country_code)
154 + values (:id, :slug, :name, :normalized_name, :kind, :country_code)
155 + on conflict (slug) do nothing""", id=oid, slug=slug, name=name, normalized_name=norm,
156 + kind="agency" if kind in ("agency", "intergovernmental") else "operator", country_code=cc)
157 + r = await fetch_all(conn, "select id from organizations where slug = :s", s=slug)
158 + org_id = r[0]["id"]
159 + existing[slug] = org_id
160 + await execute(conn, """insert into organization_aliases (organization_id, alias, normalized, source_id) values (:oid, :alias, :normalized, 'satelliteindex')
161 + on conflict (organization_id, normalized) do nothing""", oid=org_id, alias=name, normalized=norm)
162 + await execute(conn, """
163 + insert into owner_codes (code, name, kind, country_code, organization_id) values (:code, :name, :kind, :country_code, :organization_id)
164 + on conflict (code) do update set name = excluded.name, kind = excluded.kind, country_code = excluded.country_code, organization_id = excluded.organization_id""",
165 + code=code, name=name, kind=kind, country_code=cc, organization_id=org_id)
166 + counts["owner_codes"] = len(OWNER_CODES)
167 +
168 + # constellations
169 + import json as _json
170 + for c in registry.constellations():
171 + op_id = existing.get(c.operator) if c.operator else None
172 + await execute(conn, """
173 + insert into constellations (id, slug, name, operator_id, country_code, service_type, orbit_class, description, official_url, planned_count, match_patterns, celestrak_groups)
174 + values (:id, :slug, :name, :operator_id, :country_code, :service_type, :orbit_class, :description, :official_url, :planned_count, cast(:match_patterns as jsonb), cast(:celestrak_groups as jsonb))
175 + on conflict (slug) do update set name = excluded.name, operator_id = excluded.operator_id, country_code = excluded.country_code,
176 + service_type = excluded.service_type, orbit_class = excluded.orbit_class, description = coalesce(excluded.description, constellations.description),
177 + official_url = coalesce(excluded.official_url, constellations.official_url), planned_count = excluded.planned_count,
178 + match_patterns = excluded.match_patterns, celestrak_groups = excluded.celestrak_groups, updated_at = now()""",
179 + id=new_id("constellation"), slug=c.slug, name=c.name, operator_id=op_id, country_code=c.country, service_type=c.service,
180 + orbit_class=c.orbit, description=c.description, official_url=c.url, planned_count=c.planned,
181 + match_patterns=_json.dumps(list(c.raw_patterns)), celestrak_groups=_json.dumps(list(c.celestrak_groups)))
182 + counts["constellations"] = len(registry.constellations())
183 + log.info("seed complete", extra={"counts": counts})
184 + return counts
added src/satelliteindex/registry/sites.json +1 −0
@@ -0,0 +1 @@
1 +[["AFETR", "Air Force Eastern Test Range, Florida, USA"], ["AFWTR", "Air Force Western Test Range, California, USA"], ["ANDSP", "And\u00f8ya Spaceport, Nordland, Norway"], ["ALCLC", "Ala\u0302cantara Launch Center, Maranha\u0303o, Brazil"], ["BOS", "Bowen Orbital Spaceport, Queensland, Australia"], ["CAS", "Canaries Airspace"], ["DLS", "Dombarovskiy Launch Site, Russia"], ["ERAS", "Eastern Range Airspace"], ["FRGUI", "Europe's Spaceport, Kourou, French Guiana"], ["HGSTR", "Hammaguira Space Track Range, Algeria"], ["JJSLA", "Jeju Island Sea Launch Area, Republic of Korea"], ["JSC", "Jiuquan Satellite Launch Center, PRC"], ["KODAK", "Kodiak Launch Complex, Alaska, USA"], ["KSCUT", "Uchinoura Space Center(Fomerly Kagoshima Space Center\u2014University of Tokyo, Japan)"], ["KWAJ", "US Army Kwajalein Atoll (USAKA)"], ["KYMSC", "Kapustin Yar Missile and Space Complex, Russia"], ["NSC", "Naro Space Complex, Republic of Korea"], ["PLMSC", "Plesetsk Missile and Space Complex, Russia"], ["RLLB", "Rocket Lab Launch Base, Mahia Peninsula, New Zealand"], ["SCSLA", "South China Sea Launch Area, PRC"], ["SEAL", "Sea Launch Platform (mobile)"], ["SEMLS", "Semnan Satellite Launch Site, Iran"], ["SMTS", "Shahrud Missile Test Site, Iran"], ["SNMLP", "San Marco Launch Platform, Indian Ocean (Kenya)"], ["SPKII", "Space Port Kii, Japan"], ["SRILR", "Satish Dhawan Space Centre, India(Formerly Sriharikota Launching Range)"], ["SUBL", "Submarine Launch Platform (mobile)"], ["SVOBO", "Svobodnyy Launch Complex, Russia"], ["TAISC", "Taiyuan Satellite Launch Center, PRC"], ["TANSC", "Tanegashima Space Center, Japan"], ["TYMSC", "Tyuratam Missile and Space Center, Kazakhstan(Also known as Baikonur Cosmodrome)"], ["UNK", "Unknown"], ["VOSTO", "Vostochny Cosmodrome, Russia"], ["WLPIS", "Wallops Island, Virginia, USA"], ["WOMRA", "Woomera, Australia"], ["WRAS", "Western Range Airspace"], ["WSC", "Wenchang Satellite Launch Site, PRC"], ["XICLF", "Xichang Satellite Launch Center, PRC"], ["YAVNE", "Yavne Launch Facility, Israel"], ["YSLA", "Yellow Sea Launch Area, PRC"], ["YUN", "Yunsong Launch Site(Sohae Satellite Launching Station),Democratic People's Republic of Korea (North Korea)"]]
\ No newline at end of file
added src/satelliteindex/services/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Domain services shared by connectors and the API."""
added src/satelliteindex/services/cache.py +156 −0
@@ -0,0 +1,156 @@
1 +"""Redis cache / locks / job queue with graceful degradation (falls back to an in-process dict when Redis is unavailable)."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +import json
6 +import logging
7 +import time
8 +import uuid
9 +from typing import Any
10 +
11 +from redis.asyncio import Redis
12 +from redis.exceptions import RedisError
13 +
14 +from satelliteindex.config import settings
15 +
16 +log = logging.getLogger(__name__)
17 +PREFIX = "si:"
18 +
19 +
20 +class Cache:
21 + def __init__(self) -> None:
22 + self._redis: Redis | None = None
23 + self._ok = False
24 + self._local: dict[str, tuple[float, str]] = {}
25 + self._local_locks: dict[str, tuple[float, str]] = {}
26 + self._jobs: list[str] = []
27 +
28 + async def connect(self) -> None:
29 + try:
30 + self._redis = Redis.from_url(settings.redis_url, decode_responses=True, socket_connect_timeout=2, socket_timeout=2)
31 + await self._redis.ping()
32 + self._ok = True
33 + except (RedisError, OSError) as exc:
34 + log.warning("redis unavailable, using in-process cache", extra={"error": str(exc)})
35 + self._ok = False
36 +
37 + async def close(self) -> None:
38 + if self._redis is not None:
39 + await self._redis.aclose()
40 +
41 + @property
42 + def healthy(self) -> bool:
43 + return self._ok
44 +
45 + async def ping(self) -> bool:
46 + if not self._redis:
47 + return False
48 + try:
49 + await self._redis.ping()
50 + self._ok = True
51 + except (RedisError, OSError):
52 + self._ok = False
53 + return self._ok
54 +
55 + # ------------------------------------------------------------------ cache
56 + async def get_json(self, key: str) -> Any | None:
57 + k = PREFIX + key
58 + if self._ok and self._redis:
59 + try:
60 + v = await self._redis.get(k)
61 + return json.loads(v) if v else None
62 + except (RedisError, OSError):
63 + self._ok = False
64 + hit = self._local.get(k)
65 + if hit and hit[0] > time.time():
66 + return json.loads(hit[1])
67 + return None
68 +
69 + async def set_json(self, key: str, value: Any, ttl_s: int) -> None:
70 + k = PREFIX + key
71 + payload = json.dumps(value, default=str, separators=(",", ":"))
72 + if self._ok and self._redis:
73 + try:
74 + await self._redis.set(k, payload, ex=ttl_s)
75 + return
76 + except (RedisError, OSError):
77 + self._ok = False
78 + self._local[k] = (time.time() + ttl_s, payload)
79 + if len(self._local) > 2000:
80 + now = time.time()
81 + for kk in [kk for kk, (exp, _) in self._local.items() if exp < now]:
82 + del self._local[kk]
83 +
84 + async def invalidate_prefix(self, prefix: str) -> None:
85 + p = PREFIX + prefix
86 + if self._ok and self._redis:
87 + try:
88 + async for k in self._redis.scan_iter(match=p + "*", count=500):
89 + await self._redis.delete(k)
90 + except (RedisError, OSError):
91 + self._ok = False
92 + for k in [k for k in self._local if k.startswith(p)]:
93 + del self._local[k]
94 +
95 + # ------------------------------------------------------------------ locks
96 + async def lock(self, name: str, ttl_s: int) -> str | None:
97 + token = uuid.uuid4().hex
98 + k = PREFIX + name
99 + if self._ok and self._redis:
100 + try:
101 + ok = await self._redis.set(k, token, nx=True, ex=ttl_s)
102 + return token if ok else None
103 + except (RedisError, OSError):
104 + self._ok = False
105 + cur = self._local_locks.get(k)
106 + if cur and cur[0] > time.time():
107 + return None
108 + self._local_locks[k] = (time.time() + ttl_s, token)
109 + return token
110 +
111 + async def unlock(self, name: str, token: str | None) -> None:
112 + if token is None:
113 + return
114 + k = PREFIX + name
115 + if self._ok and self._redis:
116 + try:
117 + if await self._redis.get(k) == token:
118 + await self._redis.delete(k)
119 + return
120 + except (RedisError, OSError):
121 + self._ok = False
122 + if self._local_locks.get(k, (0, ""))[1] == token:
123 + self._local_locks.pop(k, None)
124 +
125 + # ------------------------------------------------------------------ jobs
126 + async def push_job(self, job: dict[str, Any]) -> bool:
127 + payload = json.dumps(job)
128 + if self._ok and self._redis:
129 + try:
130 + await self._redis.rpush(PREFIX + "jobs", payload)
131 + return True
132 + except (RedisError, OSError):
133 + self._ok = False
134 + self._jobs.append(payload)
135 + return False # local queue is only visible to this process
136 +
137 + async def pop_job(self) -> dict[str, Any] | None:
138 + if self._ok and self._redis:
139 + try:
140 + v = await self._redis.lpop(PREFIX + "jobs")
141 + return json.loads(v) if v else None
142 + except (RedisError, OSError):
143 + self._ok = False
144 + return json.loads(self._jobs.pop(0)) if self._jobs else None
145 +
146 + async def queue_depth(self) -> int:
147 + if self._ok and self._redis:
148 + try:
149 + return int(await self._redis.llen(PREFIX + "jobs"))
150 + except (RedisError, OSError):
151 + self._ok = False
152 + return len(self._jobs)
153 +
154 +
155 +cache = Cache()
156 +_ = asyncio # keep import for type checkers
added src/satelliteindex/services/classify.py +69 −0
@@ -0,0 +1,69 @@
1 +"""Derived classification (constellation, operator, country, mission type) — documented in /methodology, source `satelliteindex`."""
2 +from __future__ import annotations
3 +
4 +from dataclasses import dataclass, field
5 +from typing import Any
6 +
7 +from sqlalchemy.ext.asyncio import AsyncConnection
8 +
9 +from satelliteindex import registry
10 +from satelliteindex.db import fetch_all
11 +
12 +
13 +@dataclass
14 +class Classifier:
15 + constellation_ids: dict[str, str] = field(default_factory=dict) # slug → id
16 + constellation_ops: dict[str, str | None] = field(default_factory=dict) # slug → operator id
17 + org_ids: dict[str, str] = field(default_factory=dict) # slug → id
18 + owner_map: dict[str, tuple[str | None, str | None]] = field(default_factory=dict) # code → (country_code, org_id)
19 +
20 + @classmethod
21 + async def load(cls, conn: AsyncConnection) -> Classifier:
22 + c = cls()
23 + for r in await fetch_all(conn, "select id, slug, operator_id from constellations"):
24 + c.constellation_ids[r["slug"]] = r["id"]
25 + c.constellation_ops[r["slug"]] = r["operator_id"]
26 + for r in await fetch_all(conn, "select id, slug from organizations"):
27 + c.org_ids[r["slug"]] = r["id"]
28 + for r in await fetch_all(conn, "select code, country_code, organization_id from owner_codes"):
29 + c.owner_map[r["code"]] = (r["country_code"], r["organization_id"])
30 + return c
31 +
32 + def classify(self, name: str, owner_code: str | None, object_type: str, *, group_hint: str | None = None) -> dict[str, Any]:
33 + """→ constellation_id, operator_id, country_code, mission_type, method."""
34 + out: dict[str, Any] = {"constellation_id": None, "operator_id": None, "country_code": None, "mission_type": None, "method": None}
35 + country, owner_org = self.owner_map.get(owner_code or "", (None, None))
36 + out["country_code"] = country
37 + spec = None
38 + if group_hint:
39 + spec = registry.constellation_by_group(group_hint)
40 + if spec:
41 + out["method"] = "celestrak_group"
42 + if spec is None and object_type in ("PAYLOAD", "STATION", "UNKNOWN"):
43 + spec = registry.match_constellation(name)
44 + if spec:
45 + out["method"] = "name_pattern"
46 + if spec is not None:
47 + out["constellation_id"] = self.constellation_ids.get(spec.slug)
48 + out["operator_id"] = self.constellation_ops.get(spec.slug) or (self.org_ids.get(spec.operator) if spec.operator else None)
49 + out["mission_type"] = spec.service
50 + if spec.country and not country:
51 + out["country_code"] = spec.country
52 + if out["operator_id"] is None and owner_org:
53 + out["operator_id"] = owner_org
54 + if out["mission_type"] is None:
55 + if object_type == "ROCKET_BODY":
56 + out["mission_type"] = "rocket-body"
57 + elif object_type == "DEBRIS":
58 + out["mission_type"] = "debris"
59 + elif object_type == "STATION":
60 + out["mission_type"] = "station"
61 + else:
62 + out["mission_type"] = registry.match_mission(name) or "unknown"
63 + return out
64 +
65 +
66 +def object_type_from_name(name: str, satcat_type: str) -> str:
67 + if satcat_type == "PAYLOAD" and (name.startswith("ISS (") or name.startswith("CSS (") or name == "TIANGONG" or name.startswith("TIANHE")):
68 + return "STATION"
69 + return satcat_type
added src/satelliteindex/services/events.py +44 −0
@@ -0,0 +1,44 @@
1 +"""Event creation with deduplication (one canonical event per dedupe key)."""
2 +from __future__ import annotations
3 +
4 +import json
5 +from datetime import datetime
6 +from typing import Any
7 +
8 +from sqlalchemy.ext.asyncio import AsyncConnection
9 +
10 +from satelliteindex.db import execute, execute_many
11 +from satelliteindex.ids import new_id
12 +
13 +
14 +async def emit_events(conn: AsyncConnection, events: list[dict[str, Any]]) -> int:
15 + """events: type, title, summary, event_time, source_id, dedupe_key, confidence, metadata, entities=[(type,id,relationship)]."""
16 + if not events:
17 + return 0
18 + rows = []
19 + ent_rows = []
20 + for e in events:
21 + eid = new_id("event")
22 + rows.append({"id": eid, "type": e["type"], "title": e["title"][:300], "summary": (e.get("summary") or "")[:2000] or None,
23 + "event_time": e["event_time"], "confidence": e.get("confidence", 1.0), "source_id": e.get("source_id"),
24 + "source_url": e.get("source_url"), "dedupe_key": e.get("dedupe_key"), "metadata": json.dumps(e.get("metadata") or {}, default=str)})
25 + for et, ei, rel in e.get("entities", []):
26 + ent_rows.append({"eid": eid, "et": et, "ei": ei, "rel": rel, "key": e.get("dedupe_key")})
27 + await execute_many(conn, """
28 + insert into events (id, type, title, summary, event_time, confidence, source_id, source_url, dedupe_key, metadata)
29 + values (:id, :type, :title, :summary, :event_time, :confidence, :source_id, :source_url, :dedupe_key, cast(:metadata as jsonb))
30 + on conflict (dedupe_key) do nothing""", rows)
31 + # entity links must reference the *kept* event id (dedupe may have kept an older row)
32 + if ent_rows:
33 + await execute_many(conn, """
34 + insert into event_entities (event_id, entity_type, entity_id, relationship)
35 + select e.id, :et, :ei, :rel from events e where e.dedupe_key = :key
36 + on conflict do nothing""", ent_rows)
37 + return len(rows)
38 +
39 +
40 +def iso(dt: datetime | None) -> str | None:
41 + return dt.isoformat() if dt else None
42 +
43 +
44 +__all__ = ["emit_events", "execute"]
added src/satelliteindex/services/positions.py +113 −0
@@ -0,0 +1,113 @@
1 +"""Satellite position service: in-process SGP4 batch propagator + short-lived cache (Redis when available)."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +import logging
6 +import time
7 +from datetime import UTC, datetime, timedelta
8 +from typing import Any
9 +
10 +import numpy as np
11 +
12 +from satelliteindex.config import settings
13 +from satelliteindex.db import connection, fetch_all, fetch_one
14 +from satelliteindex.orbital.propagate import BatchPropagator, Elements
15 +
16 +log = logging.getLogger(__name__)
17 +
18 +RELOAD_S = 600 # rebuild the Satrec array at most every 10 minutes (or when orbital_state changed)
19 +
20 +
21 +class PositionService:
22 + def __init__(self) -> None:
23 + self._prop: BatchPropagator | None = None
24 + self._meta: list[dict[str, Any]] = []
25 + self._loaded_at = 0.0
26 + self._state_version: str | None = None
27 + self._lock = asyncio.Lock()
28 + self._cache: dict[str, tuple[float, Any]] = {}
29 +
30 + async def ensure_loaded(self) -> None:
31 + async with self._lock:
32 + now = time.time()
33 + if self._prop is not None and now - self._loaded_at < RELOAD_S:
34 + return
35 + async with connection() as conn:
36 + ver = await fetch_one(conn, "select max(updated_at) as v, count(*) as n from orbital_state")
37 + version = f"{ver['v']}|{ver['n']}" if ver else None
38 + if self._prop is not None and version == self._state_version:
39 + self._loaded_at = now
40 + return
41 + rows = await fetch_all(conn, """
42 + select o.satellite_id, s.norad_id, s.canonical_name, s.slug, s.object_type, s.status, s.mission_type, s.constellation_id, s.country_code,
43 + o.epoch, o.mean_motion, o.eccentricity, o.inclination, o.raan, o.arg_of_perigee, o.mean_anomaly, o.bstar, o.mean_motion_dot,
44 + o.mean_motion_ddot, o.orbit_class, o.perigee_km, o.apogee_km, o.period_minutes
45 + from orbital_state o join satellites s on s.id = o.satellite_id
46 + where s.decay_date is null and o.epoch > now() - interval '30 days'
47 + order by s.norad_id""")
48 + els = [Elements(satellite_id=r["satellite_id"], norad_id=r["norad_id"], epoch=r["epoch"], mean_motion=r["mean_motion"], eccentricity=r["eccentricity"],
49 + inclination=r["inclination"], raan=r["raan"], arg_of_perigee=r["arg_of_perigee"], mean_anomaly=r["mean_anomaly"], bstar=r["bstar"],
50 + mean_motion_dot=r["mean_motion_dot"], mean_motion_ddot=r["mean_motion_ddot"]) for r in rows]
51 + t0 = time.perf_counter()
52 + self._prop = BatchPropagator(els)
53 + self._meta = rows
54 + self._loaded_at = now
55 + self._state_version = version
56 + self._cache.clear()
57 + log.info("propagator loaded", extra={"objects": len(els), "ms": int((time.perf_counter() - t0) * 1000)})
58 +
59 + @property
60 + def count(self) -> int:
61 + return len(self._meta)
62 +
63 + async def snapshot(self, t: datetime | None = None, *, step_s: int = 30) -> dict[str, Any]:
64 + """Compact payload for the globe: parallel arrays + a second time slice for client-side interpolation."""
65 + await self.ensure_loaded()
66 + assert self._prop is not None
67 + t = (t or datetime.now(UTC)).astimezone(UTC)
68 + bucket = int(t.timestamp() // settings.positions_ttl_s) * settings.positions_ttl_s
69 + key = f"snap:{bucket}:{step_s}"
70 + hit = self._cache.get(key)
71 + if hit and time.time() - hit[0] < settings.positions_ttl_s * 2:
72 + return hit[1]
73 + t0 = datetime.fromtimestamp(bucket, UTC)
74 + t1 = t0 + timedelta(seconds=step_s)
75 + p0 = self._prop.positions(t0)
76 + p1 = self._prop.positions(t1)
77 + ok = p0["ok"] & p1["ok"] & (p0["alt"] > 80) & (p0["alt"] < 500000)
78 + idx = np.nonzero(ok)[0]
79 + cls_codes = {"LEO": 0, "MEO": 1, "GEO": 2, "HEO": 3, "OTHER": 4}
80 + mis_codes = {"communications": 0, "earth-observation": 1, "navigation": 2, "weather": 3, "science": 4, "military": 5, "technology": 6, "iot": 7,
81 + "station": 8, "unknown": 9}
82 + meta = self._meta
83 + payload = {
84 + "t0": t0.isoformat().replace("+00:00", "Z"), "t1": t1.isoformat().replace("+00:00", "Z"), "step_s": step_s,
85 + "count": int(len(idx)), "total_tracked": len(meta),
86 + "fields": ["lat0", "lon0", "alt0", "lat1", "lon1", "alt1"],
87 + "norad": [int(meta[i]["norad_id"] or 0) for i in idx],
88 + "cls": [cls_codes.get(meta[i]["orbit_class"] or "OTHER", 4) for i in idx],
89 + "mission": [mis_codes.get(meta[i]["mission_type"] or "unknown", 9) for i in idx],
90 + "active": [1 if meta[i]["status"] == "ACTIVE" else 0 for i in idx],
91 + "pos": np.round(np.stack([p0["lat"][idx], p0["lon"][idx], p0["alt"][idx], p1["lat"][idx], p1["lon"][idx], p1["alt"][idx]], axis=1), 2).astype(float).ravel().tolist(),
92 + "vel": np.round(p0["vel"][idx], 2).astype(float).tolist(),
93 + "legend": {"cls": list(cls_codes), "mission": list(mis_codes)},
94 + }
95 + self._cache[key] = (time.time(), payload)
96 + for k in [k for k in self._cache if k != key]:
97 + del self._cache[k]
98 + return payload
99 +
100 + async def one(self, satellite_id: str, t: datetime | None = None) -> dict[str, Any] | None:
101 + await self.ensure_loaded()
102 + for i, m in enumerate(self._meta):
103 + if m["satellite_id"] == satellite_id:
104 + from satelliteindex.orbital.propagate import propagate_one
105 + el = self._prop.elements[i] # type: ignore[union-attr]
106 + res = propagate_one(el, (t or datetime.now(UTC)))
107 + res["source_epoch"] = m["epoch"].isoformat()
108 + res["orbit_class"] = m["orbit_class"]
109 + return res
110 + return None
111 +
112 +
113 +positions = PositionService()
added src/satelliteindex/services/resolution.py +204 −0
@@ -0,0 +1,204 @@
1 +"""Entity resolution for satellites.
2 +
3 +Priority (CLAUDE.md §7): NORAD → COSPAR → exact normalized name → create. Ambiguous matches (COSPAR + different NORAD,
4 +or a name that resolves to several objects) are never merged automatically: they go to `manual_review_queue`.
5 +"""
6 +from __future__ import annotations
7 +
8 +import json
9 +import logging
10 +from dataclasses import dataclass, field
11 +from datetime import UTC, datetime
12 +from typing import Any
13 +
14 +from sqlalchemy.ext.asyncio import AsyncConnection
15 +
16 +from satelliteindex.db import execute, execute_many, fetch_all, fetch_one
17 +from satelliteindex.ids import new_id, normalize_name, satellite_slug
18 +
19 +log = logging.getLogger(__name__)
20 +
21 +
22 +@dataclass
23 +class SatIndex:
24 + """In-memory view of the satellites table for a bulk run (70 k rows fit comfortably)."""
25 + by_norad: dict[int, dict[str, Any]] = field(default_factory=dict)
26 + by_cospar: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
27 + by_name: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
28 + slugs: set[str] = field(default_factory=set)
29 +
30 + @classmethod
31 + async def load(cls, conn: AsyncConnection) -> SatIndex:
32 + rows = await fetch_all(conn, """select id, slug, canonical_name, normalized_name, norad_id, cospar_id, object_type, status, ops_status_code,
33 + owner_code, country_code, operator_id, constellation_id, launch_id, launch_date, launch_site_code, decay_date,
34 + mission_type, orbit_class, period_minutes, inclination_deg, apogee_km, perigee_km, rcs_m2, orbit_center, orbit_type,
35 + has_gp, latest_epoch from satellites""")
36 + idx = cls()
37 + for r in rows:
38 + idx.add(r)
39 + idx.slugs = {r["slug"] for r in rows}
40 + return idx
41 +
42 + def add(self, r: dict[str, Any]) -> None:
43 + if r.get("norad_id") is not None:
44 + self.by_norad[int(r["norad_id"])] = r
45 + if r.get("cospar_id"):
46 + self.by_cospar.setdefault(r["cospar_id"], []).append(r)
47 + self.by_name.setdefault(r["normalized_name"], []).append(r)
48 + self.slugs.add(r["slug"])
49 +
50 + def resolve(self, *, norad_id: int | None, cospar_id: str | None, name: str) -> tuple[dict[str, Any] | None, str]:
51 + """→ (existing row or None, method)."""
52 + if norad_id is not None and norad_id in self.by_norad:
53 + return self.by_norad[norad_id], "norad"
54 + if cospar_id and norad_id is None:
55 + cands = self.by_cospar.get(cospar_id, [])
56 + if len(cands) == 1:
57 + return cands[0], "cospar"
58 + norm = normalize_name(name)
59 + cands = [c for c in self.by_name.get(norm, []) if c.get("norad_id") is None]
60 + if len(cands) == 1 and norad_id is None:
61 + return cands[0], "name"
62 + return None, "none"
63 +
64 + def unique_slug(self, name: str, norad_id: int | None) -> str:
65 + base = satellite_slug(name, norad_id)
66 + slug, i = base, 2
67 + while slug in self.slugs:
68 + slug = f"{base}-{i}"
69 + i += 1
70 + self.slugs.add(slug)
71 + return slug
72 +
73 +
74 +TRACKED_FIELDS = ("status", "canonical_name", "object_type", "operator_id", "constellation_id", "orbit_class", "decay_date", "country_code", "owner_code")
75 +
76 +
77 +async def create_satellites(conn: AsyncConnection, idx: SatIndex, rows: list[dict[str, Any]], source_id: str) -> int:
78 + """Bulk insert brand-new objects. Each row: canonical_name, norad_id, cospar_id + optional columns."""
79 + if not rows:
80 + return 0
81 + now = datetime.now(UTC)
82 + payload = []
83 + ids = []
84 + for r in rows:
85 + sid = new_id("satellite")
86 + slug = idx.unique_slug(r["canonical_name"], r.get("norad_id"))
87 + rec = {
88 + "id": sid, "slug": slug, "canonical_name": r["canonical_name"], "normalized_name": normalize_name(r["canonical_name"]),
89 + "norad_id": r.get("norad_id"), "cospar_id": r.get("cospar_id"), "object_type": r.get("object_type", "UNKNOWN"),
90 + "status": r.get("status", "UNKNOWN"), "ops_status_code": r.get("ops_status_code"), "owner_code": r.get("owner_code"),
91 + "country_code": r.get("country_code"), "operator_id": r.get("operator_id"), "constellation_id": r.get("constellation_id"),
92 + "launch_id": r.get("launch_id"), "launch_date": r.get("launch_date"), "launch_site_code": r.get("launch_site_code"),
93 + "decay_date": r.get("decay_date"), "mission_type": r.get("mission_type"), "orbit_class": r.get("orbit_class"),
94 + "period_minutes": r.get("period_minutes"), "inclination_deg": r.get("inclination_deg"), "apogee_km": r.get("apogee_km"),
95 + "perigee_km": r.get("perigee_km"), "rcs_m2": r.get("rcs_m2"), "orbit_center": r.get("orbit_center"), "orbit_type": r.get("orbit_type"),
96 + "has_gp": r.get("has_gp", False), "latest_epoch": r.get("latest_epoch"), "first_seen_at": now, "last_seen_at": now,
97 + }
98 + payload.append(rec)
99 + idx.add(rec)
100 + ids.append((sid, rec))
101 + await execute_many(conn, """
102 + insert into satellites (id, slug, canonical_name, normalized_name, norad_id, cospar_id, object_type, status, ops_status_code, owner_code, country_code,
103 + operator_id, constellation_id, launch_id, launch_date, launch_site_code, decay_date, mission_type, orbit_class, period_minutes, inclination_deg,
104 + apogee_km, perigee_km, rcs_m2, orbit_center, orbit_type, has_gp, latest_epoch, first_seen_at, last_seen_at)
105 + values (:id, :slug, :canonical_name, :normalized_name, :norad_id, :cospar_id, :object_type, :status, :ops_status_code, :owner_code, :country_code,
106 + :operator_id, :constellation_id, :launch_id, :launch_date, :launch_site_code, :decay_date, :mission_type, :orbit_class, :period_minutes, :inclination_deg,
107 + :apogee_km, :perigee_km, :rcs_m2, :orbit_center, :orbit_type, :has_gp, :latest_epoch, :first_seen_at, :last_seen_at)""", payload)
108 + await execute_many(conn, "insert into satellite_slugs (slug, satellite_id) values (:slug, :id) on conflict (slug) do nothing",
109 + [{"slug": r["slug"], "id": r["id"]} for r in payload])
110 + idents = []
111 + for sid, rec in ids:
112 + if rec["norad_id"] is not None:
113 + idents.append({"eid": sid, "src": source_id, "t": "norad", "v": str(rec["norad_id"])})
114 + if rec["cospar_id"]:
115 + idents.append({"eid": sid, "src": source_id, "t": "cospar", "v": rec["cospar_id"]})
116 + if idents:
117 + await execute_many(conn, """insert into entity_identifiers (entity_type, entity_id, source_id, identifier_type, identifier_value, verified)
118 + values ('satellite', :eid, :src, :t, :v, true)
119 + on conflict (entity_type, entity_id, identifier_type, identifier_value) do update set last_seen_at = now()""", idents)
120 + aliases = [{"sid": sid, "alias": rec["canonical_name"], "norm": rec["normalized_name"], "src": source_id} for sid, rec in ids]
121 + await execute_many(conn, """insert into satellite_aliases (satellite_id, alias, normalized, source_id) values (:sid, :alias, :norm, :src)
122 + on conflict (satellite_id, normalized) do nothing""", aliases)
123 + return len(payload)
124 +
125 +
126 +async def apply_updates(conn: AsyncConnection, idx: SatIndex, updates: list[tuple[dict[str, Any], dict[str, Any]]], source_id: str) -> int:
127 + """`updates` = [(existing_row, changes)]. Writes changed columns, status history for tracked fields, slug aliases on rename."""
128 + if not updates:
129 + return 0
130 + history: list[dict[str, Any]] = []
131 + slug_rows: list[dict[str, Any]] = []
132 + alias_rows: list[dict[str, Any]] = []
133 + by_cols: dict[tuple[str, ...], list[dict[str, Any]]] = {}
134 + for row, changes in updates:
135 + changes = {k: v for k, v in changes.items() if row.get(k) != v}
136 + if not changes:
137 + continue
138 + for f in TRACKED_FIELDS:
139 + if f in changes:
140 + history.append({"sid": row["id"], "field": f, "old": _s(row.get(f)), "new": _s(changes[f]), "src": source_id})
141 + if "canonical_name" in changes:
142 + changes["normalized_name"] = normalize_name(changes["canonical_name"])
143 + new_slug = idx.unique_slug(changes["canonical_name"], row.get("norad_id"))
144 + changes["slug"] = new_slug
145 + slug_rows.append({"slug": new_slug, "id": row["id"]})
146 + alias_rows.append({"sid": row["id"], "alias": changes["canonical_name"], "norm": changes["normalized_name"], "src": source_id})
147 + cols = tuple(sorted(changes))
148 + by_cols.setdefault(cols, []).append({"id": row["id"], **changes})
149 + row.update(changes)
150 + for cols, rows in by_cols.items():
151 + sets = ", ".join(f"{c} = :{c}" for c in cols)
152 + await execute_many(conn, f"update satellites set {sets}, last_seen_at = now(), updated_at = now() where id = :id", rows)
153 + if history:
154 + await execute_many(conn, """insert into satellite_status_history (satellite_id, field, old_value, new_value, source_id)
155 + values (:sid, :field, :old, :new, :src)""", history)
156 + if slug_rows:
157 + await execute_many(conn, "insert into satellite_slugs (slug, satellite_id) values (:slug, :id) on conflict (slug) do nothing", slug_rows)
158 + await execute_many(conn, """insert into satellite_aliases (satellite_id, alias, normalized, source_id) values (:sid, :alias, :norm, :src)
159 + on conflict (satellite_id, normalized) do nothing""", alias_rows)
160 + return sum(len(v) for v in by_cols.values())
161 +
162 +
163 +async def touch_seen(conn: AsyncConnection, ids: list[str]) -> None:
164 + if ids:
165 + await execute(conn, "update satellites set last_seen_at = now() where id = any(:ids)", ids=ids)
166 +
167 +
168 +async def record_provenance(conn: AsyncConnection, rows: list[dict[str, Any]]) -> None:
169 + """rows: entity_type, entity_id, field_name, field_value, source_id, source_record_id, confidence."""
170 + if not rows:
171 + return
172 + await execute_many(conn, """
173 + insert into field_provenance (entity_type, entity_id, field_name, field_value, source_id, source_record_id, confidence, observed_at)
174 + values (:entity_type, :entity_id, :field_name, :field_value, :source_id, :source_record_id, :confidence, now())
175 + on conflict (entity_type, entity_id, field_name, source_id) do update set field_value = excluded.field_value,
176 + source_record_id = excluded.source_record_id, confidence = excluded.confidence, observed_at = now()""", rows)
177 +
178 +
179 +async def flag(conn: AsyncConnection, entity_type: str, entity_id: str, flag_name: str, detail: str | None = None) -> None:
180 + await execute(conn, """insert into data_quality_flags (entity_type, entity_id, flag, detail) values (:t, :i, :f, :d)
181 + on conflict (entity_type, entity_id, flag) do update set detail = excluded.detail, resolved_at = null""",
182 + t=entity_type, i=entity_id, f=flag_name, d=detail)
183 +
184 +
185 +async def flags_bulk(conn: AsyncConnection, rows: list[dict[str, Any]]) -> None:
186 + if rows:
187 + await execute_many(conn, """insert into data_quality_flags (entity_type, entity_id, flag, detail) values (:t, :i, :f, :d)
188 + on conflict (entity_type, entity_id, flag) do update set detail = excluded.detail, resolved_at = null""", rows)
189 +
190 +
191 +async def queue_review(conn: AsyncConnection, kind: str, a: tuple[str, str], b: tuple[str, str] | None, confidence: float, detail: dict[str, Any]) -> None:
192 + exists = await fetch_one(conn, """select id from manual_review_queue where kind = :k and entity_a_id = :a and coalesce(entity_b_id,'') = :b and status = 'open'""",
193 + k=kind, a=a[1], b=b[1] if b else "")
194 + if exists:
195 + return
196 + await execute(conn, """insert into manual_review_queue (kind, entity_a_type, entity_a_id, entity_b_type, entity_b_id, confidence, detail)
197 + values (:k, :at, :a, :bt, :b, :c, cast(:d as jsonb))""",
198 + k=kind, at=a[0], a=a[1], bt=b[0] if b else None, b=b[1] if b else None, c=confidence, d=json.dumps(detail, default=str))
199 +
200 +
201 +def _s(v: Any) -> str | None:
202 + if v is None:
203 + return None
204 + return v.isoformat() if hasattr(v, "isoformat") else str(v)
added src/satelliteindex/worker/__init__.py +0 −0
added src/satelliteindex/worker/scheduler.py +105 −0
@@ -0,0 +1,105 @@
1 +"""Scheduler process: runs every enabled connector on its interval, guarded by a Redis lock (safe with several nodes/processes).
2 +
3 +Also consumes the `si:jobs` Redis list (manual reruns from /admin) and runs `derived_analytics` right after any ingestion that changed data.
4 +Daily backup at 04:40 local time.
5 +"""
6 +from __future__ import annotations
7 +
8 +import asyncio
9 +import logging
10 +import signal
11 +import subprocess
12 +import sys
13 +from datetime import UTC, datetime
14 +from typing import Any
15 +
16 +from apscheduler.schedulers.asyncio import AsyncIOScheduler
17 +from apscheduler.triggers.cron import CronTrigger
18 +from apscheduler.triggers.interval import IntervalTrigger
19 +
20 +from satelliteindex.config import settings
21 +from satelliteindex.connectors import build, registry
22 +from satelliteindex.db import connection, dispose, fetch_all
23 +from satelliteindex.logging import setup_logging
24 +from satelliteindex.services.cache import cache
25 +
26 +log = logging.getLogger("scheduler")
27 +LOCK_TTL_S = 3 * 3600
28 +
29 +
30 +async def run_connector(name: str, *, force: bool = False, chain_derived: bool = True) -> dict[str, Any] | None:
31 + token = await cache.lock(f"lock:connector:{name}", LOCK_TTL_S)
32 + if token is None:
33 + log.info("lock held elsewhere, skipping", extra={"connector": name})
34 + return None
35 + try:
36 + c = await build(name)
37 + ctx = await c.run(force=force)
38 + result = {"connector": name, "status": "unchanged" if ctx.stats.unchanged else "ok", "created": ctx.stats.created, "updated": ctx.stats.updated}
39 + if chain_derived and name != "derived_analytics" and not ctx.stats.unchanged and (ctx.stats.created or ctx.stats.updated):
40 + await cache.invalidate_prefix("api:")
41 + await run_connector("derived_analytics", force=True, chain_derived=False)
42 + if name == "derived_analytics":
43 + await cache.invalidate_prefix("api:")
44 + return result
45 + except Exception as exc: # noqa: BLE001 — recorded by the connector itself
46 + log.error("connector failed", extra={"connector": name, "error": str(exc)})
47 + return {"connector": name, "status": "failed", "error": str(exc)}
48 + finally:
49 + await cache.unlock(f"lock:connector:{name}", token)
50 +
51 +
52 +async def tick_due() -> None:
53 + """Run every enabled connector whose next_run_at is due (or never ran)."""
54 + async with connection() as conn:
55 + rows = await fetch_all(conn, """select name from connectors where enabled and (next_run_at is null or next_run_at <= now())
56 + and (circuit_open_until is null or circuit_open_until <= now()) order by priority desc""")
57 + for r in rows:
58 + if r["name"] in registry():
59 + await run_connector(r["name"])
60 +
61 +
62 +async def drain_jobs() -> None:
63 + while True:
64 + job = await cache.pop_job()
65 + if not job:
66 + return
67 + name = job.get("connector")
68 + if name in registry():
69 + log.info("manual job", extra={"connector": name})
70 + await run_connector(name, force=True)
71 +
72 +
73 +def backup_job() -> None:
74 + try:
75 + subprocess.run([sys.executable, "-m", "satelliteindex.cli", "backup"], check=True, timeout=1800)
76 + except Exception as exc: # noqa: BLE001
77 + log.error("backup failed", extra={"error": str(exc)})
78 +
79 +
80 +async def _main() -> None:
81 + setup_logging(service="si-scheduler")
82 + settings.ensure_dirs()
83 + await cache.connect()
84 + sched = AsyncIOScheduler(timezone="UTC")
85 + sched.add_job(tick_due, IntervalTrigger(seconds=120), id="tick", max_instances=1, coalesce=True, next_run_time=datetime.now(UTC))
86 + sched.add_job(drain_jobs, IntervalTrigger(seconds=10), id="jobs", max_instances=1, coalesce=True)
87 + sched.add_job(backup_job, CronTrigger(hour=8, minute=40, timezone="UTC"), id="backup") # 04:40 America/Toronto (EDT)
88 + sched.start()
89 + log.info("scheduler started", extra={"connectors": sorted(registry())})
90 + stop = asyncio.Event()
91 + loop = asyncio.get_running_loop()
92 + for sig in (signal.SIGINT, signal.SIGTERM):
93 + loop.add_signal_handler(sig, stop.set)
94 + await stop.wait()
95 + sched.shutdown(wait=False)
96 + await dispose()
97 + await cache.close()
98 +
99 +
100 +def main() -> None:
101 + asyncio.run(_main())
102 +
103 +
104 +if __name__ == "__main__":
105 + main()
added tests/fixtures/celestrak_stations.json +1 −0
@@ -0,0 +1 @@
1 +[{"OBJECT_NAME":"ISS (ZARYA)","OBJECT_ID":"1998-067A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":25544,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58508,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"POISK","OBJECT_ID":"2009-060A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":36086,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58566,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"CSS (TIANHE)","OBJECT_ID":"2021-035A","EPOCH":"2026-09-11T04:35:30.942240","MEAN_MOTION":15.59805334,"ECCENTRICITY":0.00025978,"INCLINATION":41.4684,"RA_OF_ASC_NODE":159.8628,"ARG_OF_PERICENTER":270.5901,"MEAN_ANOMALY":89.4639,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":48274,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":30659,"BSTAR":0.00022085086,"MEAN_MOTION_DOT":0.00017939,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"ISS (NAUKA)","OBJECT_ID":"2021-066A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":49044,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58570,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"FREGAT DEB","OBJECT_ID":"2011-037PF","EPOCH":"2026-09-10T18:18:10.920096","MEAN_MOTION":12.44310857,"ECCENTRICITY":0.0939887,"INCLINATION":51.6432,"RA_OF_ASC_NODE":104.184,"ARG_OF_PERICENTER":112.3887,"MEAN_ANOMALY":257.9218,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":49271,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":23569,"BSTAR":0.02378,"MEAN_MOTION_DOT":0.00016723,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"CSS (WENTIAN)","OBJECT_ID":"2022-085A","EPOCH":"2026-09-11T04:35:30.942240","MEAN_MOTION":15.59805334,"ECCENTRICITY":0.00025978,"INCLINATION":41.4684,"RA_OF_ASC_NODE":159.8628,"ARG_OF_PERICENTER":270.5901,"MEAN_ANOMALY":89.4639,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":53239,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":28823,"BSTAR":0.00022085086,"MEAN_MOTION_DOT":0.00017939,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"CSS (MENGTIAN)","OBJECT_ID":"2022-143A","EPOCH":"2026-09-11T04:35:30.942240","MEAN_MOTION":15.59805334,"ECCENTRICITY":0.00025978,"INCLINATION":41.4684,"RA_OF_ASC_NODE":159.8628,"ARG_OF_PERICENTER":270.5901,"MEAN_ANOMALY":89.4639,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":54216,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":30744,"BSTAR":0.00022085086,"MEAN_MOTION_DOT":0.00017939,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"HRC MONOBLOCK CAMERA","OBJECT_ID":"1998-067XR","EPOCH":"2026-09-11T05:54:08.730432","MEAN_MOTION":15.92813053,"ECCENTRICITY":0.00049647,"INCLINATION":51.6156,"RA_OF_ASC_NODE":191.9243,"ARG_OF_PERICENTER":118.3604,"MEAN_ANOMALY":241.7904,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":66052,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":5162,"BSTAR":0.00055610428,"MEAN_MOTION_DOT":0.00194457,"MEAN_MOTION_DDOT":2.5408873e-5},{"OBJECT_NAME":"SZ-21 MODULE","OBJECT_ID":"2025-246C","EPOCH":"2026-09-09T09:29:30.201216","MEAN_MOTION":15.84583552,"ECCENTRICITY":0.00119485,"INCLINATION":41.4709,"RA_OF_ASC_NODE":139.8686,"ARG_OF_PERICENTER":264.4361,"MEAN_ANOMALY":95.5124,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":66515,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":4706,"BSTAR":0.00025519538,"MEAN_MOTION_DOT":0.00061047,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"DUPLEX","OBJECT_ID":"1998-067XS","EPOCH":"2026-09-11T06:39:52.494624","MEAN_MOTION":15.69233212,"ECCENTRICITY":0.0004446,"INCLINATION":51.6242,"RA_OF_ASC_NODE":212.6333,"ARG_OF_PERICENTER":120.9992,"MEAN_ANOMALY":239.1442,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":66906,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":4414,"BSTAR":0.00036658269,"MEAN_MOTION_DOT":0.0004327,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"KNACKSAT-2","OBJECT_ID":"1998-067XZ","EPOCH":"2026-09-11T07:01:09.547968","MEAN_MOTION":15.68357883,"ECCENTRICITY":0.00078339,"INCLINATION":51.6239,"RA_OF_ASC_NODE":218.7617,"ARG_OF_PERICENTER":147.0851,"MEAN_ANOMALY":213.0634,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":67683,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":3375,"BSTAR":0.00049913241,"MEAN_MOTION_DOT":0.00057179,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"GXIBA-1","OBJECT_ID":"1998-067YB","EPOCH":"2026-09-10T04:52:24.657024","MEAN_MOTION":15.71935856,"ECCENTRICITY":0.00084963,"INCLINATION":51.6211,"RA_OF_ASC_NODE":221.956,"ARG_OF_PERICENTER":139.9225,"MEAN_ANOMALY":220.2401,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":67685,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":3361,"BSTAR":0.00062720466,"MEAN_MOTION_DOT":0.00083939,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"UITMSAT-2","OBJECT_ID":"1998-067YC","EPOCH":"2026-09-10T14:14:52.382976","MEAN_MOTION":15.82332066,"ECCENTRICITY":0.000784,"INCLINATION":51.6208,"RA_OF_ASC_NODE":214.7641,"ARG_OF_PERICENTER":114.832,"MEAN_ANOMALY":245.3498,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":67686,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":3374,"BSTAR":0.00083074233,"MEAN_MOTION_DOT":0.00179058,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"LEOPARD","OBJECT_ID":"1998-067YD","EPOCH":"2026-09-10T04:30:49.279104","MEAN_MOTION":15.72701512,"ECCENTRICITY":0.00078996,"INCLINATION":51.6246,"RA_OF_ASC_NODE":222.6444,"ARG_OF_PERICENTER":114.3581,"MEAN_ANOMALY":245.8243,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":67687,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":3352,"BSTAR":0.00097406922,"MEAN_MOTION_DOT":0.00135834,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"HMU-SAT2","OBJECT_ID":"1998-067YE","EPOCH":"2026-09-11T05:59:57.979104","MEAN_MOTION":15.77053957,"ECCENTRICITY":0.00076919,"INCLINATION":51.624,"RA_OF_ASC_NODE":213.944,"ARG_OF_PERICENTER":117.1737,"MEAN_ANOMALY":243.0048,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":67688,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":3373,"BSTAR":0.00071201227,"MEAN_MOTION_DOT":0.00119638,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"CREW DRAGON 12","OBJECT_ID":"2026-031A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":67796,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58597,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"CYGNUS NG-24","OBJECT_ID":"2026-079A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":68689,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58609,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"PROGRESS-MS 34","OBJECT_ID":"2026-093A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":68837,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58613,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"TIANZHOU-10","OBJECT_ID":"2026-102A","EPOCH":"2026-09-11T04:35:30.942240","MEAN_MOTION":15.59805334,"ECCENTRICITY":0.00025978,"INCLINATION":41.4684,"RA_OF_ASC_NODE":159.8628,"ARG_OF_PERICENTER":270.5901,"MEAN_ANOMALY":89.4639,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":69049,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":30737,"BSTAR":0.00022085086,"MEAN_MOTION_DOT":0.00017939,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"SHENZHOU-23 (SZ-23)","OBJECT_ID":"2026-113A","EPOCH":"2026-09-11T04:35:30.942240","MEAN_MOTION":15.59805334,"ECCENTRICITY":0.00025978,"INCLINATION":41.4684,"RA_OF_ASC_NODE":159.8628,"ARG_OF_PERICENTER":270.5901,"MEAN_ANOMALY":89.4639,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":69180,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":30726,"BSTAR":0.00022085086,"MEAN_MOTION_DOT":0.00017939,"MEAN_MOTION_DDOT":0},{"OBJECT_NAME":"SOYUZ-MS 29","OBJECT_ID":"2026-162A","EPOCH":"2026-09-11T04:13:00.214752","MEAN_MOTION":15.49076359,"ECCENTRICITY":0.00049932,"INCLINATION":51.6305,"RA_OF_ASC_NODE":234.5133,"ARG_OF_PERICENTER":127.7196,"MEAN_ANOMALY":232.4246,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":100057,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":58594,"BSTAR":9.7240059e-5,"MEAN_MOTION_DOT":4.925e-5,"MEAN_MOTION_DDOT":0}]
added tests/fixtures/satcat_head.csv +200 −0
@@ -0,0 +1,200 @@
1 +OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER,LAUNCH_DATE,LAUNCH_SITE,DECAY_DATE,PERIOD,INCLINATION,APOGEE,PERIGEE,RCS,DATA_STATUS_CODE,ORBIT_CENTER,ORBIT_TYPE
2 +SL-1 R/B,1957-001A,1,R/B,D,CIS,1957-10-04,TYMSC,1957-12-01,96.19,65.10,938,214,20.4200,,EA,IMP
3 +SPUTNIK 1,1957-001B,2,PAY,D,CIS,1957-10-04,TYMSC,1958-01-03,96.10,65.00,1080,64,,,EA,IMP
4 +SPUTNIK 2,1957-002A,3,PAY,D,CIS,1957-11-03,TYMSC,1958-04-14,103.74,65.33,1659,211,0.0800,,EA,IMP
5 +EXPLORER 1,1958-001A,4,PAY,D,US,1958-02-01,AFETR,1970-03-31,88.48,33.15,215,183,,,EA,IMP
6 +VANGUARD 1,1958-002B,5,PAY,,US,1958-03-17,AFETR,,132.59,34.25,3818,653,0.1220,,EA,ORB
7 +EXPLORER 3,1958-003A,6,PAY,D,US,1958-03-26,AFETR,1958-06-28,,,,,,,EA,IMP
8 +SL-1 R/B,1958-004A,7,R/B,D,CIS,1958-05-15,TYMSC,1958-12-03,102.74,65.14,1571,206,,,EA,IMP
9 +SPUTNIK 3,1958-004B,8,PAY,D,CIS,1958-05-15,TYMSC,1960-04-06,88.43,65.06,255,139,11.8400,,EA,IMP
10 +EXPLORER 4,1958-005A,9,PAY,D,US,1958-07-26,AFETR,1959-10-23,92.81,50.25,585,239,,,EA,IMP
11 +SCORE,1958-006A,10,PAY,D,US,1958-12-18,AFETR,1959-01-21,98.21,32.29,1187,159,,,EA,IMP
12 +VANGUARD 2,1959-001A,11,PAY,,US,1959-02-17,AFETR,,120.94,32.87,2894,552,0.3931,,EA,ORB
13 +VANGUARD R/B,1959-001B,12,R/B,,US,1959-02-17,AFETR,,125.37,32.90,3284,554,0.5266,,EA,ORB
14 +DISCOVERER 1,1959-002A,13,PAY,D,US,1959-02-28,AFWTR,1959-03-03,,,,,,,EA,IMP
15 +DISCOVERER 2,1959-003A,14,PAY,D,US,1959-04-13,AFWTR,1959-04-26,,,,,,,EA,IMP
16 +EXPLORER 6,1959-004A,15,PAY,D,US,1959-08-07,AFETR,1961-06-30,762.25,46.95,42276,251,,,EA,IMP
17 +VANGUARD R/B,1958-002A,16,R/B,,US,1958-03-17,AFETR,,137.19,34.26,4210,657,0.2282,,EA,ORB
18 +THOR ABLE R/B,1959-004B,17,R/B,D,US,1959-08-07,AFETR,1961-06-30,718.96,47.10,40215,198,0.0200,,EA,IMP
19 +DISCOVERER 5,1959-005A,18,PAY,D,US,1959-08-13,AFWTR,1959-09-28,89.10,79.99,323,137,,,EA,IMP
20 +DISCOVERER 6,1959-006A,19,PAY,D,US,1959-08-19,AFWTR,1959-10-20,90.92,84.00,469,171,,,EA,IMP
21 +VANGUARD 3,1959-007A,20,PAY,,US,1959-09-18,AFETR,,123.92,33.34,3203,508,0.6412,,EA,ORB
22 +LUNA 3,1959-008A,21,PAY,D,CIS,1959-10-04,TYMSC,1960-04-20,21563.22,55.00,499998,500,,,EA,IMP
23 +EXPLORER 7,1959-009A,22,PAY,,US,1959-10-13,AFETR,,94.08,50.24,525,423,0.5003,,EA,ORB
24 +JUNO II R/B,1959-009B,23,R/B,D,US,1959-10-13,AFETR,1989-07-16,89.24,50.27,249,225,0.5778,,EA,IMP
25 +DISCOVERER 7,1959-010A,24,PAY,D,US,1959-11-07,AFWTR,1959-11-26,91.54,81.64,539,161,,,EA,IMP
26 +DISCOVERER 8,1959-011A,25,PAY,D,US,1959-11-20,AFWTR,1960-03-08,88.03,80.65,239,115,,,EA,IMP
27 +DISCOVERER 5 CAPSULE,1959-005B,26,PAY,D,US,1959-08-13,AFWTR,1961-02-11,88.73,78.94,280,143,,,EA,IMP
28 +PIONEER 5,1960-001A,27,PAY,,US,1960-03-11,AFETR,,,,,,0.0008,NEA,SU,ORB
29 +THOR ABLE R/B,1960-002A,28,R/B,D,US,1960-04-01,AFETR,1991-07-03,87.80,48.34,168,163,1.6017,,EA,IMP
30 +TIROS 1,1960-002B,29,PAY,-,US,1960-04-01,AFETR,,97.37,48.38,649,617,0.8030,,EA,ORB
31 +THOR ABLESTAR R/B,1960-003A,30,R/B,D,US,1960-04-13,AFETR,1961-08-18,88.38,51.25,194,194,,,EA,IMP
32 +TRANSIT 1B,1960-003B,31,PAY,D,US,1960-04-13,AFETR,1967-10-05,88.70,51.20,220,201,,,EA,IMP
33 +DISCOVERER 11,1960-004A,32,PAY,D,US,1960-04-15,AFWTR,1960-04-26,89.70,80.10,377,142,,,EA,IMP
34 +THOR ABLESTAR DEB,1960-003C,33,DEB,D,US,1960-04-13,AFETR,1960-07-17,93.59,51.29,615,285,,,EA,IMP
35 +SPUTNIK 4,1960-005A,34,PAY,D,CIS,1960-05-15,TYMSC,1962-09-05,87.82,64.96,167,167,,,EA,IMP
36 +SL-3 R/B,1960-005B,35,R/B,D,CIS,1960-05-15,TYMSC,1960-07-17,88.48,64.89,199,199,,,EA,IMP
37 +SPUTNIK 4 CABIN,1960-005C,36,PAY,D,CIS,1960-05-15,TYMSC,1965-10-15,88.26,64.96,206,171,,,EA,IMP
38 +SPUTNIK 4 DEB,1960-005D,37,DEB,D,CIS,1960-05-15,TYMSC,1961-06-30,92.92,64.89,552,282,,,EA,IMP
39 +SPUTNIK 4 DEB,1960-005E,38,DEB,D,CIS,1960-05-15,TYMSC,1960-08-20,90.63,64.89,328,282,,,EA,IMP
40 +SPUTNIK 4 DEB,1960-005F,39,DEB,D,CIS,1960-05-15,TYMSC,1960-09-24,90.99,64.89,364,283,,,EA,IMP
41 +SPUTNIK 4 DEB,1960-005G,40,DEB,D,CIS,1960-05-15,TYMSC,1960-09-24,92.13,64.89,475,282,,,EA,IMP
42 +SPUTNIK 4 DEB,1960-005H,41,DEB,D,CIS,1960-05-15,TYMSC,1960-09-30,90.74,64.89,339,283,,,EA,IMP
43 +SPUTNIK 4 DEB,1960-005J,42,DEB,D,CIS,1960-05-15,TYMSC,1960-10-01,91.25,64.89,420,251,,,EA,IMP
44 +MIDAS 2,1960-006A,43,PAY,D,US,1960-05-24,AFETR,1974-02-07,87.82,33.05,168,165,,,EA,IMP
45 +MIDAS 2 DEB,1960-006B,44,DEB,D,US,1960-05-24,AFETR,1960-12-05,89.06,33.00,228,228,,,EA,IMP
46 +TRANSIT 2A,1960-007A,45,PAY,,US,1960-06-22,AFETR,,100.28,66.69,944,599,0.4299,,EA,ORB
47 +SOLRAD 1 (GREB),1960-007B,46,PAY,,US,1960-06-22,AFETR,,98.97,66.69,852,567,0.3501,,EA,ORB
48 +THOR ABLESTAR R/B,1960-007C,47,R/B,,US,1960-06-22,AFETR,,99.63,66.66,891,591,2.9661,,EA,ORB
49 +DISCOVERER 13,1960-008A,48,PAY,D,US,1960-08-10,AFWTR,1960-11-14,89.39,82.85,272,216,,,EA,IMP
50 +ECHO 1,1960-009A,49,PAY,D,US,1960-08-12,AFETR,1968-05-24,92.70,47.19,419,394,,,EA,IMP
51 +DELTA 1 R/B,1960-009B,50,R/B,,US,1960-08-12,AFETR,,118.02,47.23,1683,1501,0.5940,,EA,ORB
52 +ECHO 1 DEB [METAL OBJ],1960-009C,51,DEB,,US,1960-08-12,AFETR,,118.19,47.21,1686,1513,0.5289,,EA,ORB
53 +ECHO 1 DEB [MYLAR OBJ],1960-009D,52,DEB,,US,1960-08-12,AFETR,,118.26,47.38,1711,1494,0.0030,,EA,ORB
54 +ECHO 1 DEB [METAL OBJ],1960-009E,53,DEB,,US,1960-08-12,AFETR,,118.28,47.28,1682,1525,0.5810,,EA,ORB
55 +DISCOVERER 14,1960-010A,54,PAY,D,US,1960-08-18,AFWTR,1960-09-16,87.51,79.65,151,151,,,EA,IMP
56 +SPUTNIK 5,1960-011A,55,PAY,D,CIS,1960-08-19,TYMSC,1960-08-20,90.67,64.90,307,307,,,EA,IMP
57 +SL-3 R/B,1960-011B,56,R/B,D,CIS,1960-08-19,TYMSC,1960-09-23,87.95,64.94,177,168,,,EA,IMP
58 +DISCOVERER 15,1960-012A,57,PAY,D,US,1960-09-13,AFWTR,1960-10-18,88.97,80.90,315,133,,,EA,IMP
59 +COURIER 1B,1960-013A,58,PAY,,US,1960-10-04,AFETR,,106.95,28.33,1208,962,1.2843,,EA,ORB
60 +THOR ABLESTAR R/B,1960-013B,59,R/B,,US,1960-10-04,AFETR,,106.42,28.25,1201,920,3.4517,,EA,ORB
61 +EXPLORER 8,1960-014A,60,PAY,D,US,1960-11-03,AFETR,2012-03-28,87.84,49.88,177,158,0.4520,,EA,IMP
62 +DISCOVERER 17,1960-015A,61,PAY,D,US,1960-11-12,AFWTR,1960-12-29,88.41,81.86,226,166,,,EA,IMP
63 +JUNO II R/B,1960-014B,62,R/B,D,US,1960-11-03,AFETR,1985-10-27,87.74,49.89,166,160,0.6450,,EA,IMP
64 +TIROS 2,1960-016A,63,PAY,D,US,1960-11-23,AFETR,2014-05-18,87.72,48.44,163,160,0.8780,,EA,IMP
65 +DELTA 1 R/B,1960-016B,64,R/B,D,US,1960-11-23,AFETR,1981-09-24,88.54,48.47,208,196,0.6288,,EA,IMP
66 +SPUTNIK 6,1960-017A,65,PAY,D,CIS,1960-12-01,TYMSC,1960-12-02,88.38,65.00,218,171,,,EA,IMP
67 +SL-3 R/B,1960-017B,66,R/B,D,CIS,1960-12-01,TYMSC,1960-12-02,87.28,65.00,140,140,,,EA,IMP
68 +DISCOVERER 18,1960-018A,67,PAY,D,US,1960-12-07,AFWTR,1961-04-02,89.08,81.48,279,179,,,EA,IMP
69 +DISCOVERER 19,1960-019A,68,PAY,D,US,1960-12-20,AFWTR,1961-01-23,89.36,83.40,307,179,,,EA,IMP
70 +JUNO II DEB,1960-014C,69,DEB,D,US,1960-11-03,AFETR,1970-02-16,90.28,49.27,310,266,,,EA,IMP
71 +SAMOS 2,1961-001A,70,PAY,D,US,1961-01-31,AFWTR,1973-10-21,88.09,97.22,185,175,,,EA,IMP
72 +SPUTNIK 7,1961-002A,71,PAY,D,CIS,1961-02-04,TYMSC,1961-02-26,89.26,64.90,296,180,,,EA,IMP
73 +SL-6 R/B,1961-002B,72,R/B,D,CIS,1961-02-04,TYMSC,1961-02-13,88.87,64.90,228,209,,,EA,IMP
74 +SL-6 DEB,1961-002C,73,DEB,D,CIS,1961-02-04,TYMSC,1961-03-17,88.21,64.89,227,145,,,EA,IMP
75 +DELTA 1 DEB,1960-016C,74,DEB,D,US,1960-11-23,AFETR,1989-05-10,89.38,48.54,250,238,0.1777,,EA,IMP
76 +DELTA 1 DEB,1960-016D,75,DEB,D,US,1960-11-23,AFETR,1990-01-25,88.84,48.50,225,209,0.2325,,EA,IMP
77 +SL-6 R/B,1961-003B,76,R/B,D,CIS,1961-02-12,TYMSC,1961-02-18,88.98,65.01,238,211,,,EA,IMP
78 +SPUTNIK 8,1961-003C,77,PAY,D,CIS,1961-02-12,TYMSC,1961-02-25,88.61,65.01,242,169,,,EA,IMP
79 +SL-6 DEB,1961-003D,78,DEB,D,CIS,1961-02-12,TYMSC,1961-02-18,89.53,65.01,305,198,,,EA,IMP
80 +ATLAS AGENA A R/B,1961-001B,79,R/B,D,US,1961-01-31,AFWTR,1970-10-09,87.27,97.35,161,118,,,EA,IMP
81 +VENERA 1,1961-003A,80,PAY,,CIS,1961-02-12,TYMSC,,,,,,0.0039,NEA,SU,ORB
82 +EXPLORER 9,1961-004A,81,PAY,D,US,1961-02-16,WLPIS,1964-04-09,93.01,38.84,559,285,,,EA,IMP
83 +SCOUT X-1 R/B,1961-004B,82,R/B,,US,1961-02-16,WLPIS,,117.27,38.86,2484,632,0.5330,,EA,ORB
84 +DISCOVERER 20,1961-005A,83,PAY,D,US,1961-02-17,AFWTR,1962-07-28,88.58,80.83,204,204,,,EA,IMP
85 +DISCOVERER 21,1961-006A,84,PAY,D,US,1961-02-18,AFWTR,1962-04-20,89.30,80.63,276,204,,,EA,IMP
86 +SCOUT X-1 DEB,1961-004C,85,DEB,D,US,1961-02-16,WLPIS,2025-06-03,92.87,38.63,447,383,0.0504,,EA,IMP
87 +SCOUT X-1 DEB,1961-004D,86,DEB,D,US,1961-02-16,WLPIS,1961-06-30,,,,,,,EA,IMP
88 +TRANSIT 3B & LOFTI 1,1961-007A,87,PAY,D,US,1961-02-22,AFETR,1961-03-30,88.09,28.38,225,135,,,EA,IMP
89 +DISCOVERER 20 DEB,1961-005B,88,DEB,D,US,1961-02-17,AFWTR,1961-04-02,90.14,80.91,281,281,,,EA,IMP
90 +DISCOVERER 20 DEB,1961-005C,89,DEB,D,US,1961-02-17,AFWTR,1961-04-20,92.80,80.91,539,284,,,EA,IMP
91 +DISCOVERER 20 DEB,1961-005D,90,DEB,D,US,1961-02-17,AFWTR,1961-10-31,91.01,80.71,327,321,,,EA,IMP
92 +SPUTNIK 9,1961-008A,91,PAY,D,CIS,1961-03-09,TYMSC,1961-03-09,87.83,64.93,213,121,,,EA,IMP
93 +SPUTNIK 9 DEB,1961-008B,92,DEB,D,CIS,1961-03-09,TYMSC,1961-03-10,88.19,64.93,200,170,,,EA,IMP
94 +SPUTNIK 9 DEB,1961-008C,93,DEB,D,CIS,1961-03-09,TYMSC,1961-03-10,87.94,64.93,173,173,,,EA,IMP
95 +SPUTNIK 9 DEB,1961-008D,94,DEB,D,CIS,1961-03-09,TYMSC,1961-03-10,,,,,,NIE,EA,IMP
96 +SPUTNIK 10,1961-009A,95,PAY,D,CIS,1961-03-25,TYMSC,1961-03-25,88.00,65.00,176,176,,,EA,IMP
97 +SL-3 R/B,1961-009B,96,R/B,D,CIS,1961-03-25,TYMSC,1961-03-26,87.82,65.00,198,135,,,EA,IMP
98 +SL-3 DEB,1961-009C,97,DEB,D,CIS,1961-03-25,TYMSC,1961-03-26,88.53,65.00,237,167,,,EA,IMP
99 +EXPLORER 10,1961-010A,98,PAY,D,US,1961-03-25,AFETR,1961-06-30,,,,,,,EA,IMP
100 +THOR ABLESTAR DEB,1960-003D,99,DEB,D,US,1960-04-13,AFETR,1979-07-15,88.98,51.26,227,221,0.0628,,EA,IMP
101 +DISCOVERER 23,1961-011A,100,PAY,D,US,1961-04-08,AFWTR,1962-04-16,89.47,82.23,268,228,,,EA,IMP
102 +THOR ABLE DEB [YO],1960-002C,101,DEB,D,US,1960-04-01,AFETR,1983-04-17,88.03,48.47,177,177,0.1132,,EA,IMP
103 +DISCOVERER 23 CAPSULE,1961-011B,102,PAY,D,US,1961-04-08,AFWTR,1962-05-23,88.27,81.88,189,189,,,EA,IMP
104 +VOSTOK 1,1961-012A,103,PAY,D,CIS,1961-04-12,TYMSC,1961-04-12,89.33,64.95,314,168,,,EA,IMP
105 +SL-3 R/B,1961-012B,104,R/B,D,CIS,1961-04-12,TYMSC,1961-04-16,88.72,65.07,268,155,,,EA,IMP
106 +JUNO II DEB,1960-014D,105,DEB,D,US,1960-11-03,AFETR,1972-03-03,90.68,50.42,349,266,,,EA,IMP
107 +DISCOVERER 23 DEB,1961-011C,106,DEB,D,US,1961-04-08,AFWTR,1961-09-10,91.11,81.87,473,185,,,EA,IMP
108 +EXPLORER 11,1961-013A,107,PAY,,US,1961-04-27,AFETR,,101.23,28.78,1171,463,0.4538,,EA,ORB
109 +DISCOVERER 25,1961-014A,108,PAY,D,US,1961-06-16,AFWTR,1961-07-12,88.23,82.11,187,187,,,EA,IMP
110 +DISCOVERER 25 DEB,1961-014B,109,DEB,D,US,1961-06-16,AFWTR,1961-06-19,89.36,82.11,262,223,,,EA,IMP
111 +PIONEER 1,1958-007A,110,PAY,D,US,1958-10-11,AFETR,1958-10-12,,,,,,NIE,EA,IMP
112 +PIONEER 3,1958-008A,111,PAY,D,US,1958-12-06,AFETR,1958-12-07,,,,,,NIE,EA,IMP
113 +LUNA 1,1959-012A,112,PAY,,CIS,1959-01-02,TYMSC,,,,,,,NEA,SU,ORB
114 +PIONEER 4,1959-013A,113,PAY,,US,1959-03-03,AFETR,,,,,,,NEA,SU,ORB
115 +LUNA 2,1959-014A,114,PAY,D,CIS,1959-09-12,TYMSC,1959-09-13,,,,,,NIE,MO,IMP
116 +THOR ABLE DEB [YO],1960-002D,115,DEB,,US,1960-04-01,AFETR,,96.09,48.16,590,553,0.0471,,EA,ORB
117 +TRANSIT 4A,1961-015A,116,PAY,,US,1961-06-29,AFETR,,103.42,66.81,978,862,0.7486,,EA,ORB
118 +SOLRAD 3 & INJUN 1,1961-015B,117,PAY,,US,1961-06-29,AFETR,,103.58,66.81,989,867,0.4239,,EA,ORB
119 +THOR ABLESTAR R/B,1961-015C,118,R/B,,US,1961-06-29,AFETR,,102.65,66.76,947,822,1.6476,,EA,ORB
120 +THOR ABLESTAR DEB,1961-015D,119,DEB,,US,1961-06-29,AFETR,,100.88,66.73,874,727,0.2397,,EA,ORB
121 +THOR ABLESTAR DEB,1961-015E,120,DEB,,US,1961-06-29,AFETR,,100.72,66.70,876,709,0.1840,,EA,ORB
122 +THOR ABLESTAR DEB,1961-015F,121,DEB,,US,1961-06-29,AFETR,,102.84,66.75,965,821,0.2591,,EA,ORB
123 +THOR ABLESTAR DEB,1961-015G,122,DEB,,US,1961-06-29,AFETR,,95.84,66.74,581,538,0.3240,,EA,ORB
124 +THOR ABLESTAR DEB,1961-015H,123,DEB,,US,1961-06-29,AFETR,,99.88,66.69,833,672,0.1157,,EA,ORB
125 +THOR ABLESTAR DEB,1961-015J,124,DEB,,US,1961-06-29,AFETR,,100.27,66.55,815,727,0.0983,,EA,ORB
126 +THOR ABLESTAR DEB,1961-015K,125,DEB,,US,1961-06-29,AFETR,,102.29,66.87,919,815,0.0420,,EA,ORB
127 +THOR ABLESTAR DEB,1961-015L,126,DEB,,US,1961-06-29,AFETR,,99.65,66.71,786,698,0.3190,,EA,ORB
128 +THOR ABLESTAR DEB,1961-015M,127,DEB,,US,1961-06-29,AFETR,,99.39,66.83,778,681,0.3519,,EA,ORB
129 +THOR ABLESTAR DEB,1961-015N,128,DEB,,US,1961-06-29,AFETR,,102.71,66.68,960,813,0.1654,,EA,ORB
130 +THOR ABLESTAR DEB,1961-015P,129,DEB,D,US,1961-06-29,AFETR,1981-08-31,88.48,66.54,215,183,0.1076,,EA,IMP
131 +THOR ABLESTAR DEB,1961-015Q,130,DEB,,US,1961-06-29,AFETR,,102.90,66.77,962,830,0.4390,,EA,ORB
132 +THOR ABLESTAR DEB,1961-015R,131,DEB,,US,1961-06-29,AFETR,,102.72,66.73,945,830,0.1233,,EA,ORB
133 +THOR ABLESTAR DEB,1961-015S,132,DEB,,US,1961-06-29,AFETR,,103.24,66.66,963,861,0.2540,,EA,ORB
134 +THOR ABLESTAR DEB,1961-015T,133,DEB,,US,1961-06-29,AFETR,,101.18,67.12,847,783,0.6005,,EA,ORB
135 +THOR ABLESTAR DEB,1961-015U,134,DEB,,US,1961-06-29,AFETR,,103.02,66.88,957,846,1.5570,,EA,ORB
136 +THOR ABLESTAR DEB,1961-015V,135,DEB,D,US,1961-06-29,AFETR,2002-04-17,89.31,66.68,253,227,0.1500,,EA,IMP
137 +THOR ABLESTAR DEB,1961-015W,136,DEB,,US,1961-06-29,AFETR,,99.84,67.53,784,717,0.1039,,EA,ORB
138 +THOR ABLESTAR DEB,1961-015X,137,DEB,D,US,1961-06-29,AFETR,1980-12-15,92.26,66.55,405,366,0.0719,,EA,IMP
139 +THOR ABLESTAR DEB,1961-015Y,138,DEB,,US,1961-06-29,AFETR,,102.50,67.15,895,859,0.1008,,EA,ORB
140 +THOR ABLESTAR DEB,1961-015Z,139,DEB,D,US,1961-06-29,AFETR,1978-07-08,92.00,67.12,400,346,0.3498,,EA,IMP
141 +THOR ABLESTAR DEB,1961-015AA,140,DEB,D,US,1961-06-29,AFETR,1962-09-30,89.92,66.59,311,230,,,EA,IMP
142 +THOR ABLESTAR DEB,1961-015AB,141,DEB,,US,1961-06-29,AFETR,,103.67,66.59,972,892,0.3000,,EA,ORB
143 +THOR ABLESTAR DEB,1961-015AC,142,DEB,D,US,1961-06-29,AFETR,1967-12-25,90.94,67.06,328,313,,,EA,IMP
144 +THOR ABLESTAR DEB,1961-015AD,143,DEB,D,US,1961-06-29,AFETR,1962-06-16,89.84,66.24,294,239,,,EA,IMP
145 +THOR ABLESTAR DEB,1961-015AE,144,DEB,,US,1961-06-29,AFETR,,103.47,66.50,958,888,0.0791,,EA,ORB
146 +THOR ABLESTAR DEB,1961-015AF,145,DEB,,US,1961-06-29,AFETR,,102.61,66.93,932,833,0.1138,,EA,ORB
147 +THOR ABLESTAR DEB,1961-015AG,146,DEB,D,US,1961-06-29,AFETR,1969-07-07,87.32,66.95,190,93,,,EA,IMP
148 +THOR ABLESTAR DEB,1961-015AH,147,DEB,,US,1961-06-29,AFETR,,101.10,66.62,846,776,0.1196,,EA,ORB
149 +THOR ABLESTAR DEB,1961-015AJ,148,DEB,,US,1961-06-29,AFETR,,102.06,65.77,966,746,0.1240,,EA,ORB
150 +THOR ABLESTAR DEB,1961-015AK,149,DEB,D,US,1961-06-29,AFETR,1979-03-03,95.67,67.08,559,542,0.0562,,EA,IMP
151 +THOR ABLESTAR DEB,1961-015AL,150,DEB,,US,1961-06-29,AFETR,,104.05,66.25,997,903,0.2067,,EA,ORB
152 +THOR ABLESTAR DEB,1961-015AM,151,DEB,D,US,1961-06-29,AFETR,1967-12-02,91.65,66.84,385,326,,,EA,IMP
153 +THOR ABLESTAR DEB,1961-015AN,152,DEB,,US,1961-06-29,AFETR,,102.01,66.59,959,749,0.2350,,EA,ORB
154 +THOR ABLESTAR DEB,1961-015AP,153,DEB,D,US,1961-06-29,AFETR,1981-04-20,92.10,67.09,391,364,0.0675,,EA,IMP
155 +THOR ABLESTAR DEB,1961-015AQ,154,DEB,,US,1961-06-29,AFETR,,102.39,66.66,935,808,0.2710,,EA,ORB
156 +THOR ABLESTAR DEB,1961-015AR,155,DEB,,US,1961-06-29,AFETR,,99.14,66.88,746,689,0.0380,,EA,ORB
157 +THOR ABLESTAR DEB,1961-015AS,156,DEB,D,US,1961-06-29,AFETR,1998-09-25,89.86,66.96,281,254,0.0803,,EA,IMP
158 +THOR ABLESTAR DEB,1961-015AT,157,DEB,D,US,1961-06-29,AFETR,1981-01-08,90.90,66.71,330,308,0.3500,,EA,IMP
159 +THOR ABLESTAR DEB,1961-015AU,158,DEB,,US,1961-06-29,AFETR,,113.72,67.07,1872,922,0.1867,,EA,ORB
160 +THOR ABLESTAR DEB,1961-015AV,159,DEB,,US,1961-06-29,AFETR,,110.51,65.53,1524,976,0.1792,,EA,ORB
161 +DISCOVERER 26,1961-016A,160,PAY,D,US,1961-07-07,AFWTR,1961-12-05,88.99,82.89,267,182,,,EA,IMP
162 +THOR ABLESTAR DEB,1961-015AW,161,DEB,D,US,1961-06-29,AFETR,1981-08-29,88.07,66.66,201,158,0.1661,,EA,IMP
163 +TIROS 3,1961-017A,162,PAY,-,US,1961-07-12,AFETR,,99.63,47.90,772,709,0.8244,,EA,ORB
164 +MIDAS 3,1961-018A,163,PAY,,US,1961-07-12,AFWTR,,161.46,91.13,3545,3343,9.2155,,EA,ORB
165 +MIDAS 3 DEB,1961-018B,164,DEB,D,US,1961-07-12,AFWTR,1961-07-24,101.81,90.80,1559,129,,,EA,IMP
166 +DELTA 1 R/B,1961-017B,165,R/B,D,US,1961-07-12,AFETR,2014-02-19,87.76,47.82,169,158,0.4062,,EA,IMP
167 +DELTA 1 DEB [YO],1961-017C,166,DEB,D,US,1961-07-12,AFETR,1992-03-02,89.41,47.93,249,241,0.1500,,EA,IMP
168 +DELTA 1 DEB [YO],1961-017D,167,DEB,,US,1961-07-12,AFETR,,101.11,47.85,876,747,0.0460,,EA,ORB
169 +VOSTOK 2,1961-019A,168,PAY,D,CIS,1961-08-06,TYMSC,1961-08-07,88.42,64.84,239,154,,,EA,IMP
170 +SL-3 R/B,1961-019B,169,R/B,D,CIS,1961-08-06,TYMSC,1961-08-09,88.21,64.91,192,180,,,EA,IMP
171 +EXPLORER 12,1961-020A,170,PAY,D,US,1961-08-15,AFETR,1963-09-30,1594.62,33.83,76977,693,,,EA,IMP
172 +THOR ABLESTAR DEB,1961-015AX,171,DEB,D,US,1961-06-29,AFETR,1962-01-29,88.78,67.13,214,214,,,EA,IMP
173 +THOR ABLESTAR DEB,1961-015AY,172,DEB,,US,1961-06-29,AFETR,,105.94,67.07,1135,941,0.0720,,EA,ORB
174 +RANGER 1,1961-021A,173,PAY,D,US,1961-08-23,AFETR,1961-08-30,91.31,32.93,506,172,,,EA,IMP
175 +ATLAS AGENA B R/B,1961-021B,174,R/B,D,US,1961-08-23,AFETR,1961-09-03,90.27,32.88,399,176,,,EA,IMP
176 +THOR ABLESTAR DEB,1961-015AZ,175,DEB,D,US,1961-06-29,AFETR,1999-03-12,90.26,66.52,302,272,0.0544,,EA,IMP
177 +THOR ABLESTAR DEB,1961-015BA,176,DEB,D,US,1961-06-29,AFETR,1982-02-12,91.22,66.73,352,316,0.0115,,EA,IMP
178 +THOR ABLESTAR DEB,1961-015BB,177,DEB,,US,1961-06-29,AFETR,,101.77,66.66,918,768,0.1110,,EA,ORB
179 +THOR ABLESTAR DEB,1961-015BC,178,DEB,,US,1961-06-29,AFETR,,103.37,66.87,980,856,0.0960,,EA,ORB
180 +THOR ABLESTAR DEB,1961-015BD,179,DEB,D,US,1961-06-29,AFETR,2000-03-03,90.45,66.85,308,285,0.1259,,EA,IMP
181 +EXPLORER 13,1961-022A,180,PAY,D,US,1961-08-25,WLPIS,1961-08-28,96.43,36.42,810,365,,,EA,IMP
182 +DISCOVERER 29,1961-023A,181,PAY,D,US,1961-08-30,AFWTR,1961-09-10,90.73,82.08,490,131,,,EA,IMP
183 +DISCOVERER 30,1961-024A,182,PAY,D,US,1961-09-12,AFWTR,1961-12-11,89.21,82.62,281,190,,,EA,IMP
184 +MERCURY ATLAS 4,1961-025A,183,PAY,D,US,1961-09-13,AFETR,1961-09-13,88.40,32.54,241,149,,,EA,IMP
185 +ATLAS D R/B,1961-025B,184,R/B,D,US,1961-09-13,AFETR,1961-09-13,87.47,32.58,149,149,,,EA,IMP
186 +DISCOVERER 30 DEB,1961-024B,185,DEB,D,US,1961-09-12,AFWTR,1961-09-18,90.72,82.58,400,220,,,EA,IMP
187 +DISCOVERER 31,1961-026A,186,PAY,D,US,1961-09-17,AFWTR,1961-10-26,89.63,82.68,304,209,,,EA,IMP
188 +DISCOVERER 30 DEB,1961-024C,187,DEB,D,US,1961-09-12,AFWTR,1961-09-28,90.80,82.71,421,206,,,EA,IMP
189 +MIDAS 3 DEB,1961-018C,188,DEB,,US,1961-07-12,AFWTR,,161.11,91.13,3538,3322,0.5766,,EA,ORB
190 +DISCOVERER 32,1961-027A,189,PAY,D,US,1961-10-13,AFWTR,1961-11-13,88.87,81.64,232,204,,,EA,IMP
191 +DISCOVERER 32 DEB,1961-027B,190,DEB,D,US,1961-10-13,AFWTR,1961-11-25,89.87,81.69,303,234,,,EA,IMP
192 +DISCOVERER 32 DEB,1961-027C,191,DEB,D,US,1961-10-13,AFWTR,1961-10-16,90.14,81.69,329,234,,,EA,IMP
193 +MIDAS 4,1961-028A,192,PAY,,US,1961-10-21,AFWTR,,165.94,95.85,3759,3491,7.9063,,EA,ORB
194 +MIDAS 4 DEB [NOSECONE],1961-028B,193,DEB,D,US,1961-10-21,AFWTR,1961-12-05,102.55,94.74,1612,147,,,EA,IMP
195 +MIDAS 4 DEB,1961-028C,194,DEB,,US,1961-10-21,AFWTR,,165.49,95.85,4028,3186,0.5854,,EA,ORB
196 +MIDAS 4 DEB,1961-028D,195,DEB,,US,1961-10-21,AFWTR,,166.35,95.85,3764,3519,0.5726,,EA,ORB
197 +MIDAS 3 DEB,1961-018D,196,DEB,,US,1961-07-12,AFWTR,,161.86,91.13,3546,3374,0.5559,,EA,ORB
198 +DISCOVERER 34,1961-029A,197,PAY,D,US,1961-11-05,AFWTR,1962-12-07,89.00,82.49,246,204,,,EA,IMP
199 +DISCOVERER 34 DEB,1961-029B,198,DEB,D,US,1961-11-05,AFWTR,1961-11-30,90.53,82.46,368,232,,,EA,IMP
200 +DISCOVERER 34 DEB,1961-029C,199,DEB,D,US,1961-11-05,AFWTR,1961-12-09,92.04,82.50,502,247,,,EA,IMP
added tests/test_api.py +80 −0
@@ -0,0 +1,80 @@
1 +"""API integration smoke tests (need the local database with ingested data; skipped otherwise)."""
2 +from __future__ import annotations
3 +
4 +import asyncio
5 +
6 +import httpx
7 +import pytest
8 +
9 +from satelliteindex.api.main import app
10 +from satelliteindex.db import connection, dispose, fetch_val
11 +
12 +pytestmark = pytest.mark.asyncio
13 +
14 +
15 +async def _db_ready() -> bool:
16 + try:
17 + async with connection() as conn:
18 + return int(await fetch_val(conn, "select count(*) from satellites")) > 1000
19 + except Exception: # noqa: BLE001
20 + return False
21 + finally:
22 + await dispose()
23 +
24 +
25 +@pytest.fixture(scope="module")
26 +def has_db() -> bool:
27 + return asyncio.run(_db_ready())
28 +
29 +
30 +@pytest.fixture
31 +async def client(has_db):
32 + if not has_db:
33 + pytest.skip("database not available / not ingested")
34 + async with app.router.lifespan_context(app):
35 + transport = httpx.ASGITransport(app=app)
36 + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
37 + yield c
38 +
39 +
40 +async def test_health(client):
41 + r = await client.get("/api/v1/health")
42 + assert r.status_code == 200 and r.json()["components"]["database"]["status"] == "ok"
43 +
44 +
45 +async def test_satellite_detail_and_position(client):
46 + r = await client.get("/api/v1/satellites/25544")
47 + assert r.status_code == 200
48 + d = r.json()["data"]
49 + assert d["norad_id"] == 25544 and d["object_type"] == "STATION" and d["orbit_class"] == "LEO"
50 + assert d["live"] is None or 350 < d["live"]["altitude_km"] < 460
51 + r = await client.get("/api/v1/satellites/25544/position")
52 + assert r.status_code == 200 and "lat" in r.json()["data"]
53 + r = await client.get(f"/api/v1/satellites/{d['slug']}")
54 + assert r.status_code == 200
55 + r = await client.get("/api/v1/satellites/does-not-exist-0")
56 + assert r.status_code == 404 and "error" in r.json()
57 +
58 +
59 +async def test_search_and_list(client):
60 + r = await client.get("/api/v1/search", params={"q": "ISS"})
61 + assert r.status_code == 200
62 + hits = r.json()["data"]["results"]
63 + assert any(h["entity_type"] == "satellite" and "ISS" in h["title"] for h in hits)
64 + r = await client.get("/api/v1/satellites", params={"constellation": "starlink", "status": "ACTIVE", "page_size": 5})
65 + body = r.json()
66 + assert r.status_code == 200 and body["pagination"]["total"] > 1000 and len(body["data"]) == 5
67 +
68 +
69 +async def test_stats_and_positions(client):
70 + r = await client.get("/api/v1/stats")
71 + assert r.status_code == 200 and int(r.json()["data"]["global"]["active_satellites"]) > 5000
72 + r = await client.get("/api/v1/orbit/positions")
73 + assert r.status_code == 200
74 + snap = r.json()["data"]
75 + assert snap["count"] > 5000 and len(snap["pos"]) == snap["count"] * 6
76 +
77 +
78 +async def test_admin_requires_token(client):
79 + r = await client.get("/api/v1/admin/overview")
80 + assert r.status_code == 401
added tests/test_connectors.py +65 −0
@@ -0,0 +1,65 @@
1 +"""Connector parsing and classification on fixtures — never hits the network."""
2 +from __future__ import annotations
3 +
4 +from pathlib import Path
5 +
6 +from satelliteindex import registry
7 +from satelliteindex.connectors.orbital.celestrak.gp import parse_omm
8 +from satelliteindex.connectors.orbital.celestrak.satcat import CelesTrakSatcatConnector
9 +from satelliteindex.ids import normalize_name, satellite_slug, slugify
10 +from satelliteindex.registry.reference import LAUNCH_SITES, OWNER_CODES, SATCAT_STATUS
11 +from satelliteindex.services.classify import Classifier, object_type_from_name
12 +
13 +FIX = Path(__file__).parent / "fixtures"
14 +
15 +
16 +def test_parse_omm_stations():
17 + recs = parse_omm((FIX / "celestrak_stations.json").read_text())
18 + assert len(recs) >= 10
19 + iss = next(r for r in recs if r["norad"] == 25544)
20 + assert iss["cospar"] == "1998-067A" and iss["epoch"].tzinfo is not None
21 + assert 15 < iss["mean_motion"] < 16
22 +
23 +
24 +def test_parse_satcat_head():
25 + rows = CelesTrakSatcatConnector().parse((FIX / "satcat_head.csv").read_text())
26 + assert len(rows) >= 150
27 + sputnik = next(r for r in rows if r["norad"] == 2)
28 + assert sputnik["type"] == "PAYLOAD" and sputnik["owner"] == "CIS" and sputnik["decay"] is not None and sputnik["site"] == "TYMSC"
29 + assert SATCAT_STATUS[sputnik["ops"]] == "DECAYED"
30 + assert all(r["owner"] in OWNER_CODES for r in rows if r["owner"])
31 + assert all(r["site"] in LAUNCH_SITES for r in rows if r["site"])
32 +
33 +
34 +def test_registry_patterns():
35 + assert registry.match_constellation("STARLINK-32001").slug == "starlink"
36 + assert registry.match_constellation("ONEWEB-0123").slug == "oneweb"
37 + assert registry.match_constellation("NAVSTAR 81 (USA 343)").slug == "gps"
38 + assert registry.match_constellation("ISS (ZARYA)").slug == "iss"
39 + assert registry.match_constellation("COSMOS 2576").slug == "cosmos"
40 + assert registry.match_constellation("GALAXY 37 (HORIZONS 4)").slug == "intelsat"
41 + assert registry.match_constellation("FLOCK 4X-12").slug == "planet-flock"
42 + assert registry.match_constellation("RANDOM OBJECT 42") is None
43 + assert registry.match_mission("NOAA 19") == "weather"
44 + assert registry.match_mission("LANDSAT 9") == "earth-observation"
45 + assert registry.match_mission("HST") == "science"
46 +
47 +
48 +def test_classifier_offline():
49 + clf = Classifier(constellation_ids={"starlink": "con_1", "gps": "con_2"}, constellation_ops={"starlink": "org_spacex", "gps": "org_ussf"},
50 + org_ids={"spacex": "org_spacex", "us-space-force": "org_ussf"}, owner_map={"US": ("US", None), "SES": ("LU", "org_ses")})
51 + out = clf.classify("STARLINK-1007", "US", "PAYLOAD")
52 + assert out == {"constellation_id": "con_1", "operator_id": "org_spacex", "country_code": "US", "mission_type": "communications", "method": "name_pattern"}
53 + out = clf.classify("SES-17", "SES", "PAYLOAD")
54 + assert out["operator_id"] == "org_ses" and out["country_code"] == "LU"
55 + out = clf.classify("SL-16 R/B", "CIS", "ROCKET_BODY")
56 + assert out["mission_type"] == "rocket-body" and out["constellation_id"] is None
57 + assert object_type_from_name("ISS (ZARYA)", "PAYLOAD") == "STATION"
58 + assert object_type_from_name("STARLINK-1", "PAYLOAD") == "PAYLOAD"
59 +
60 +
61 +def test_ids_and_slugs():
62 + assert slugify("ISS (ZARYA)") == "iss-zarya"
63 + assert satellite_slug("ISS (ZARYA)", 25544) == "iss-zarya-25544"
64 + assert normalize_name("Space Exploration Technologies Corp.") == "SPACE EXPLORATION TECHNOLOGIES CORP"
65 + assert slugify("Ñusat-1 (Fresco)") == "nusat-1-fresco"
added tests/test_orbital.py +94 −0
@@ -0,0 +1,94 @@
1 +"""Orbital validation against known objects (ISS fixture) and classification rules."""
2 +from __future__ import annotations
3 +
4 +import json
5 +import math
6 +from datetime import UTC, datetime, timedelta
7 +from pathlib import Path
8 +
9 +import numpy as np
10 +import pytest
11 +from sgp4 import omm
12 +from sgp4.api import Satrec
13 +
14 +from satelliteindex.orbital.elements import classify_orbit, derived_geometry
15 +from satelliteindex.orbital.propagate import BatchPropagator, Elements, ground_track, jd_fr, propagate_one, teme_to_geodetic
16 +
17 +FIX = Path(__file__).parent / "fixtures"
18 +
19 +
20 +def iss_elements() -> tuple[Elements, dict]:
21 + d = json.loads((FIX / "celestrak_stations.json").read_text())[0]
22 + assert d["NORAD_CAT_ID"] == 25544
23 + el = Elements(satellite_id="sat_test", norad_id=25544, epoch=datetime.fromisoformat(d["EPOCH"]).replace(tzinfo=UTC), mean_motion=d["MEAN_MOTION"],
24 + eccentricity=d["ECCENTRICITY"], inclination=d["INCLINATION"], raan=d["RA_OF_ASC_NODE"], arg_of_perigee=d["ARG_OF_PERICENTER"],
25 + mean_anomaly=d["MEAN_ANOMALY"], bstar=d["BSTAR"], mean_motion_dot=d["MEAN_MOTION_DOT"], mean_motion_ddot=d["MEAN_MOTION_DDOT"])
26 + return el, d
27 +
28 +
29 +def test_iss_geometry_and_class():
30 + el, _ = iss_elements()
31 + g = derived_geometry(el.mean_motion, el.eccentricity)
32 + assert 400 < g["perigee_km"] < 440 and 400 < g["apogee_km"] < 440
33 + assert 92 < g["period_minutes"] < 94
34 + assert classify_orbit(period_minutes=g["period_minutes"], eccentricity=el.eccentricity, inclination_deg=el.inclination, apogee_km=g["apogee_km"], perigee_km=g["perigee_km"]) == "LEO"
35 +
36 +
37 +def test_iss_position_matches_reference_sgp4():
38 + el, d = iss_elements()
39 + t = el.epoch + timedelta(minutes=37)
40 + mine = propagate_one(el, t)
41 + ref = Satrec()
42 + omm.initialize(ref, {k: str(v) for k, v in d.items()})
43 + jd, fr = jd_fr(t)
44 + e, r, v = ref.sgp4(jd, fr)
45 + assert e == 0 and mine["error"] is None
46 + assert np.linalg.norm(np.array(r) - np.array(mine["position_teme_km"])) < 0.5 # km
47 + assert 400 < mine["altitude_km"] < 440
48 + assert 7.5 < mine["velocity_km_s"] < 7.8
49 + assert -52 < mine["lat"] < 52 # ISS inclination bound
50 +
51 +
52 +def test_ground_track_shape_and_wrap():
53 + el, _ = iss_elements()
54 + pts = ground_track(el, el.epoch, minutes_before=45, minutes_after=90, step_s=60)
55 + assert len(pts) == 136
56 + assert all(-180 <= p["lon"] <= 180 for p in pts)
57 + assert sum(1 for p in pts if p["future"]) == 91
58 +
59 +
60 +def test_batch_matches_single():
61 + el, _ = iss_elements()
62 + t = el.epoch + timedelta(hours=1)
63 + b = BatchPropagator([el, el])
64 + p = b.positions(t)
65 + s = propagate_one(el, t)
66 + assert p["ok"].all()
67 + assert abs(p["lat"][0] - s["lat"]) < 1e-6 and abs(p["alt"][1] - s["altitude_km"]) < 1e-6
68 +
69 +
70 +def test_geodetic_conversion_equator_and_pole():
71 + jd = np.array(2460000.5)
72 + r_eq = np.array([6378.137 + 500.0, 0.0, 0.0])
73 + lat, lon, alt = teme_to_geodetic(r_eq, jd)
74 + assert abs(lat) < 1e-6 and abs(alt - 500.0) < 1e-3
75 + r_pole = np.array([0.0, 0.0, 6356.7523 + 800.0])
76 + lat, lon, alt = teme_to_geodetic(r_pole, jd)
77 + assert abs(lat - 90) < 1e-4 and abs(alt - 800.0) < 0.01
78 +
79 +
80 +@pytest.mark.parametrize("mm,e,i,expected", [
81 + (1.00271, 0.0002, 0.05, "GEO"), # geostationary
82 + (2.00565, 0.01, 55.0, "MEO"), # GPS
83 + (15.5, 0.0005, 51.6, "LEO"), # ISS
84 + (2.0, 0.74, 63.4, "HEO"), # Molniya
85 + (1.00271, 0.0002, 7.0, "GEO"), # inclined geosynchronous
86 +])
87 +def test_classification_table(mm, e, i, expected):
88 + g = derived_geometry(mm, e)
89 + assert classify_orbit(period_minutes=g["period_minutes"], eccentricity=e, inclination_deg=i, apogee_km=g["apogee_km"], perigee_km=g["perigee_km"]) == expected
90 +
91 +
92 +def test_semi_major_axis_gps():
93 + g = derived_geometry(2.00565, 0.0)
94 + assert math.isclose(g["semi_major_axis_km"], 26560, rel_tol=0.002)
95