SatelliteIndex v0.1 — backend (CelesTrak connectors, SGP4, FastAPI), web scaffolding, deploy manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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’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 | ||