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
Large diff (249 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/globe/earth.tsx
+186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/globe/filters.ts
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/globe/geo.ts
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/globe/satellite-points.tsx
+225 −0
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/globe/use-positions.ts
+157 −0
Large diff (157 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/mobile-tab-bar.tsx
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-context.tsx
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-dialog.tsx
+169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-footer.tsx
+47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-header.tsx
+89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/map/world-map.tsx
+91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/satellite/altitude-chart.tsx
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/satellite/hero.tsx
+129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/satellite/live-map.tsx
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/satellite/primitives.tsx
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/satellite/view-beacon.tsx
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stats/local-charts.tsx
+78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stats/shared.tsx
+140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stats/stats-composition.tsx
+103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stats/stats-headline.tsx
+49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stats/stats-history.tsx
+92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/badges.tsx
+40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/pagination.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/section.tsx
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/unavailable.tsx
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/api.ts
+162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/client-api.ts
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/cn.ts
+3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/fonts.system.ts
+3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/fonts.ts
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/format.ts
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/site.ts
+118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/types.ts
+559 −0
Large diff (559 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/tsconfig.json
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
deploy/first-run.sh
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
deploy/render-manifest.sh
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
deploy/satelliteindex.mld.json
+113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added
docs/API.md
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
docs/DEPLOY.md
+58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added
docs/FRONTEND-GUIDE.md
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
migrations/env.py
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
migrations/script.py.mako
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
migrations/versions/0001_initial.py
+594 −0
Large diff (594 lines) is not loaded by default to keep the page fast.Load diff
added
package.json
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-lock.yaml
+1796 −0
Large diff (1,796 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-workspace.yaml
+2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
pyproject.toml
+49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/backup-offnode.sh
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/__init__.py
+3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/analytics/__init__.py
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/analytics/search.py
+81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/analytics/stats.py
+88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/common.py
+93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/main.py
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/ratelimit.py
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/admin.py
+177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/constellations.py
+71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/countries.py
+61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/events.py
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/health.py
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/launches.py
+159 −0
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/misc.py
+103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/operators.py
+61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/orbit.py
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/satellites.py
+251 −0
Large diff (251 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/search.py
+100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/api/routers/stats.py
+125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/cli.py
+204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/config.py
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/__init__.py
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/base/__init__.py
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/base/connector.py
+179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/base/fetch.py
+114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/derived.py
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/orbital/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/orbital/celestrak/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/orbital/celestrak/gp.py
+265 −0
Large diff (265 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/connectors/orbital/celestrak/satcat.py
+274 −0
Large diff (274 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/db/__init__.py
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/ids.py
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/logging.py
+53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/orbital/__init__.py
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/orbital/elements.py
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/orbital/propagate.py
+147 −0
Large diff (147 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/registry/__init__.py
+103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/registry/constellations.yaml
+925 −0
Large diff (925 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/registry/owners.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/registry/reference.py
+142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/registry/seed.py
+184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/registry/sites.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/services/__init__.py
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/services/cache.py
+156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/services/classify.py
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/services/events.py
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/services/positions.py
+113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/services/resolution.py
+204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/worker/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
src/satelliteindex/worker/scheduler.py
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/celestrak_stations.json
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/satcat_head.csv
+200 −0
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api.py
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_connectors.py
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_orbital.py
+94 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff