SPB Git

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

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

128 changed files +13,601 −0

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

Theme