commit 7b19c54943ab891fd130025c9be2ffff664a297d Author: Mavis Date: Tue Jun 2 15:47:28 2026 +0000 Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4039ff1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66956ad --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# API +PORT=4000 +NODE_ENV=development +INGEST_API_KEY=change-me-please +CORS_ORIGIN=http://localhost:3000,http://localhost:3001 + +# Postgres (Phase 1) +DATABASE_URL=postgres://kerbal:kerbal@localhost:5432/kerbal_rt + +# Redis (Phase 1) +REDIS_URL=redis://localhost:6379 + +# MinIO / S3 (Phase 1) +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_BUCKET=kerbal-rt-media +S3_ACCESS_KEY=kerbal +S3_SECRET_KEY=kerbal-secret-change-me + +# Hub +NEXT_PUBLIC_API_URL=http://localhost:4000 +NEXT_PUBLIC_MAP_URL=http://localhost:3001 + +# Live map +VITE_API_URL=http://localhost:4000 + +# Twitch / YouTube (Phase 3) +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= +YOUTUBE_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..280b1fb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + TURBO_TELEMETRY_DISABLED: 1 + +jobs: + build: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Format check + run: pnpm format:check + + - name: Typecheck + run: pnpm -r typecheck + + - name: Test + run: pnpm -r test + + - name: Build + run: pnpm -r --filter=./packages/* build && pnpm -r --filter=./apps/* build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f589a7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# dependencies +node_modules/ +.pnpm-store/ + +# builds +dist/ +build/ +.next/ +out/ +*.tsbuildinfo + +# env +.env +.env.local +.env.*.local +!.env.example + +# logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# os +.DS_Store +Thumbs.db + +# editor +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json.example +.idea/ + +# test +coverage/ +.nyc_output/ + +# misc +*.tgz +.cache/ +.turbo/ + +# KSP game (if you ever mount the install into the repo for dev) +KSP/ +saves/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8f5d69d --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# pnpm config +auto-install-peers=true +strict-peer-dependencies=false +shamefully-hoist=false +prefer-workspace-packages=true +link-workspace-packages=true +save-workspace-protocol=rolling diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b60434b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": [] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..59dec4a --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Kerbal RT + +A real-time web mirror of a no-warp Kerbal Space Program multiplayer server: +mission hub + 3D live solar system map. + +> **Status: Phase 0 (skeleton)** — monorepo scaffolded, all three apps build, +> data pipeline / real KSP bridge lands in Phase 1. + +See [`kerbalrealtime-clone-plan.md`](./kerbalrealtime-clone-plan.md) for the +full implementation plan. + +--- + +## Stack + +| Layer | Tech | +|---|---| +| **Hub** | Next.js 14 (App Router) + TypeScript | +| **Live map** | Vite + React + Three.js + TypeScript | +| **API** | Hono on Node 22 + WebSocket (`ws`) + Zod | +| **Shared** | `@kerbal-rt/shared-types`, `@kerbal-rt/orbital-math`, `@kerbal-rt/ui` | +| **Data (Phase 1)** | Postgres 16 + TimescaleDB, Redis 7, MinIO (S3) | +| **Package mgr** | pnpm workspaces | +| **Linter** | Prettier (ESLint coming per-app) | + +## Layout + +``` +. +├── apps/ +│ ├── hub/ # Next.js — marketing + calendar + media +│ ├── live-map/ # Vite + Three.js — 3D solar system viewer +│ └── api/ # Hono — REST + WS gateway +├── packages/ +│ ├── shared-types/ # TS types + Zod schemas for every API boundary +│ ├── orbital-math/ # Pure keplerian propagation, occultation, transfers +│ └── ui/ # Shared React components (Pill, Card, …) +├── ksp/ # KSP-side mods (Phase 1+) +├── infra/ # docker-compose, nginx, grafana, init SQL +└── scripts/ # dev.sh and friends +``` + +## Quick start + +```bash +# 1. Install deps +pnpm install + +# 2. Run the three apps in parallel +pnpm dev +# hub: http://localhost:3000 +# live-map: http://localhost:3001 +# api: http://localhost:4000 + +# Or run them individually +pnpm dev:hub +pnpm dev:map +pnpm dev:api +``` + +> The `dev` script for `live-map` proxies `/api/*` to `:4000`, so the live +> map's WebSocket and REST calls work out of the box. + +## Tests + +```bash +pnpm test +``` + +## Build + +```bash +pnpm build +``` + +## Data tier (Phase 1) + +The API starts in-memory (in-process `StateStore`) for development. To wire +up the real data tier: + +```bash +docker compose -f infra/docker-compose.yml up -d +``` + +This brings up: +- **TimescaleDB** on `:5432` (with the full KSP body catalog pre-seeded) +- **Redis** on `:6379` +- **MinIO** on `:9000` (S3-compatible object store for media) +- **pgAdmin** on `:5050` (only if you start with `--profile dev`) + +## CI + +GitHub Actions runs `format:check` → `typecheck` → `test` → `build` on every +push / PR to `main`. See `.github/workflows/ci.yml`. + +## Phases + +See [`kerbalrealtime-clone-plan.md`](./kerbalrealtime-clone-plan.md) for the +roadmap. Quick map: + +- [x] **Phase 0** — monorepo, skeletons, docker-compose, CI *(this commit)* +- [ ] **Phase 1** — KSP telemetry bridge + ingest pipeline (Postgres + Redis) +- [ ] **Phase 2** — Live map 3D scene (time controls, focus, eclipses, overpass) +- [ ] **Phase 3** — Mission calendar, media, crew, YouTube/Twitch embeds +- [ ] **Phase 4** — Spacenomicon tools (delta-v, transfer calculator, etc.) +- [ ] **Phase 5** — Admin panel + polish +- [ ] **Phase 6** — Production deploy + +## License + +This project is unaffiliated with Kerbal Space Program, Intercept Games, or +Private Division. All trademarks belong to their respective owners. diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..92ce7bb --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,33 @@ +{ + "name": "@kerbal-rt/api", + "version": "0.1.0", + "private": true, + "description": "Backend API: REST + WebSocket gateway to the KSP telemetry pipeline", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node --import tsx src/index.ts", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "vitest run" + }, + "dependencies": { + "@hono/node-server": "^1.13.0", + "@hono/node-ws": "^1.1.0", + "@hono/zod-validator": "^0.4.0", + "@kerbal-rt/shared-types": "workspace:*", + "@kerbal-rt/orbital-math": "workspace:*", + "hono": "^4.6.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "vitest": "^2.1.1" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..f2f16e8 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,79 @@ +/** + * Build the Hono app. Exported separately from index.ts so tests can + * import it without binding a port. + */ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { UniverseSnapshotSchema, type AppType as _ } from '@kerbal-rt/shared-types'; +import { z } from 'zod'; +import { state } from './state.js'; + +export function buildApp() { + const app = new Hono(); + + app.use('*', logger()); + app.use('*', cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*' })); + + // ─── health ───────────────────────────────────────────────────────────── + app.get('/health', (c) => + c.json({ + ok: true, + service: 'kerbal-rt-api', + version: '0.1.0', + ts: new Date().toISOString(), + }), + ); + + // ─── ingest (called by the KSP telemetry bridge) ─────────────────────── + const IngestAuth = z.object({ 'x-api-key': z.string() }); + + app.post('/api/v1/ingest', async (c) => { + const headerParse = IngestAuth.safeParse( + Object.fromEntries(Array.from(c.req.raw.headers.entries())), + ); + if (!headerParse.success) { + return c.json({ error: true, code: 'UNAUTHORIZED', message: 'Missing API key' }, 401); + } + if (headerParse.data['x-api-key'] !== process.env.INGEST_API_KEY) { + return c.json({ error: true, code: 'FORBIDDEN', message: 'Bad API key' }, 403); + } + const body = await c.req.json(); + const parsed = UniverseSnapshotSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { + error: true, + code: 'BAD_PAYLOAD', + message: 'Invalid snapshot', + details: parsed.error.format(), + }, + 400, + ); + } + state.applySnapshot(parsed.data); + return c.json({ error: false, data: { ok: true, ts: new Date().toISOString() } }); + }); + + // ─── read-only endpoints ─────────────────────────────────────────────── + app.get('/api/v1/state', (c) => { + const snap = state.latestSnapshot(); + if (!snap) + return c.json({ error: true, code: 'NO_DATA', message: 'No snapshot yet' }, 503); + return c.json({ error: false, data: snap }); + }); + + app.get('/api/v1/bodies', (c) => c.json({ error: false, data: state.latestBodies() })); + + app.get('/api/v1/vessels', (c) => c.json({ error: false, data: state.latestVessels() })); + + app.get('/api/v1/vessels/:id', (c) => { + const v = state.getVessel(c.req.param('id')); + if (!v) return c.json({ error: true, code: 'NOT_FOUND', message: 'Vessel not found' }, 404); + return c.json({ error: false, data: v }); + }); + + return app; +} + +export type AppType = ReturnType; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..6d06d3d --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,43 @@ +/** + * API entrypoint. + * + * Stack: Hono on Node + ws for WebSockets. + * Phase 0 scope: healthcheck, body catalog (static seed), vessel list + * (in-memory from /ingest), and a /live WebSocket. + * + * Real DB (Postgres + Timescale) lands in Phase 1. + */ +import { serve } from '@hono/node-server'; +import { createNodeWebSocket } from '@hono/node-ws'; +import type { ServerType } from '@hono/node-server'; +import type { LiveMessage } from '@kerbal-rt/shared-types'; +import { buildApp } from './app.js'; +import { handleLiveSocket } from './ws.js'; + +const app = buildApp(); + +const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); + +app.get( + '/api/v1/live', + upgradeWebSocket(() => ({ + onOpen: (_evt, ws) => handleLiveSocket.onOpen(ws), + onMessage: (evt, ws) => handleLiveSocket.onMessage(evt, ws), + onClose: (_evt, ws) => handleLiveSocket.onClose(ws), + })), +); + +const port = Number(process.env.PORT ?? 4000); + +const server: ServerType = serve({ fetch: app.fetch, port }, (info) => { + // eslint-disable-next-line no-console + console.log(`[kerbal-rt-api] listening on http://localhost:${info.port}`); +}); + +injectWebSocket(server); + +// Push periodic pings to all connected WS clients +setInterval(() => { + const msg: LiveMessage = { type: 'ping', serverTime: new Date().toISOString() }; + handleLiveSocket.broadcast(msg); +}, 15_000); diff --git a/apps/api/src/state.ts b/apps/api/src/state.ts new file mode 100644 index 0000000..40f0720 --- /dev/null +++ b/apps/api/src/state.ts @@ -0,0 +1,38 @@ +/** + * In-memory state store. Phase 0 only. + * + * Phase 1 swaps this for Postgres + Timescale + Redis. The interface + * stays stable: callers ask for the latest snapshot, and the store + * decides where it comes from. + */ +import type { CelestialBody, UniverseSnapshot, Vessel } from '@kerbal-rt/shared-types'; + +class StateStore { + private snapshot: UniverseSnapshot | null = null; + private vesselsById: Map = new Map(); + private bodiesById: Map = new Map(); + + applySnapshot(snap: UniverseSnapshot): void { + this.snapshot = snap; + this.vesselsById = new Map(snap.vessels.map((v) => [v.id, v])); + this.bodiesById = new Map(snap.bodies.map((b) => [b.id, b])); + } + + latestSnapshot(): UniverseSnapshot | null { + return this.snapshot; + } + + latestBodies(): CelestialBody[] { + return Array.from(this.bodiesById.values()); + } + + latestVessels(): Vessel[] { + return Array.from(this.vesselsById.values()); + } + + getVessel(id: string): Vessel | undefined { + return this.vesselsById.get(id); + } +} + +export const state = new StateStore(); diff --git a/apps/api/src/test-app.ts b/apps/api/src/test-app.ts new file mode 100644 index 0000000..bf59fd0 --- /dev/null +++ b/apps/api/src/test-app.ts @@ -0,0 +1,5 @@ +/** + * Test-only Hono app export. Doesn't bind a port. + */ +import { buildApp } from './app.js'; +export const app = buildApp(); diff --git a/apps/api/src/ws.ts b/apps/api/src/ws.ts new file mode 100644 index 0000000..dad3596 --- /dev/null +++ b/apps/api/src/ws.ts @@ -0,0 +1,38 @@ +/** + * WebSocket fan-out for /api/v1/live. + * + * Phase 0 just keeps a Set of connected clients and broadcasts any + * LiveMessage we get. Clients should handle `ping` as a heartbeat. + * + * Phase 1 will wire this to a Redis pubsub channel so multiple API + * instances can share the fan-out load. + */ +import type { LiveMessage } from '@kerbal-rt/shared-types'; +import type { WebSocket } from 'ws'; + +const clients = new Set(); + +function send(ws: WebSocket, msg: LiveMessage): void { + if (ws.readyState === 1 /* OPEN */) { + ws.send(JSON.stringify(msg)); + } +} + +export const handleLiveSocket = { + onOpen(ws: WebSocket): void { + clients.add(ws); + }, + + onMessage(_evt: { data: unknown }, _ws: WebSocket): void { + // Phase 0: clients are read-only, ignore incoming. + // Phase 2: accept subscriptions like { type: 'subscribe', vesselId: '...' } + }, + + onClose(ws: WebSocket): void { + clients.delete(ws); + }, + + broadcast(msg: LiveMessage): void { + for (const ws of clients) send(ws, msg); + }, +}; diff --git a/apps/api/tests/health.test.ts b/apps/api/tests/health.test.ts new file mode 100644 index 0000000..55be1b8 --- /dev/null +++ b/apps/api/tests/health.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { app } from '../src/test-app.js'; + +describe('health', () => { + it('returns ok', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); +}); diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..cc5774c --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..678c241 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022"], + "types": ["node"], + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*"] +} diff --git a/apps/hub/.eslintrc.json b/apps/hub/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/apps/hub/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/hub/next-env.d.ts b/apps/hub/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/hub/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/hub/next.config.mjs b/apps/hub/next.config.mjs new file mode 100644 index 0000000..7e2ddd5 --- /dev/null +++ b/apps/hub/next.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: ['@kerbal-rt/shared-types', '@kerbal-rt/ui', '@kerbal-rt/orbital-math'], + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000', + }, +}; + +export default nextConfig; diff --git a/apps/hub/package.json b/apps/hub/package.json new file mode 100644 index 0000000..4496f89 --- /dev/null +++ b/apps/hub/package.json @@ -0,0 +1,29 @@ +{ + "name": "@kerbal-rt/hub", + "version": "0.1.0", + "private": true, + "description": "Marketing/operations hub: mission calendar, crew, media, Spacenomicon", + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "start": "next start --port 3000", + "typecheck": "tsc --noEmit", + "lint": "next lint", + "test": "echo 'no tests yet'" + }, + "dependencies": { + "@kerbal-rt/shared-types": "workspace:*", + "@kerbal-rt/ui": "workspace:*", + "next": "^14.2.13", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.13", + "typescript": "^5.6.2" + } +} diff --git a/apps/hub/src/app/globals.css b/apps/hub/src/app/globals.css new file mode 100644 index 0000000..1ef61fe --- /dev/null +++ b/apps/hub/src/app/globals.css @@ -0,0 +1,48 @@ +:root { + --background: #0a0a0f; + --foreground: #e6e6ee; + --card-bg: rgba(255, 255, 255, 0.03); + --card-border: #2a2a3a; + --pill-neutral: #4a4a5a; + --pill-success: #2c5; + --pill-warn: #fa3; + --pill-danger: #e44; + --link: #6cf; +} + +@media (prefers-color-scheme: light) { + :root { + --background: #fafafa; + --foreground: #1a1a1a; + --card-bg: rgba(0, 0, 0, 0.02); + --card-border: #d8d8d8; + --pill-neutral: #aaa; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--background); + color: var(--foreground); + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--link); +} + +code { + background: rgba(255, 255, 255, 0.08); + padding: 0.1em 0.3em; + border-radius: 3px; + font-size: 0.9em; +} diff --git a/apps/hub/src/app/layout.tsx b/apps/hub/src/app/layout.tsx new file mode 100644 index 0000000..aaec522 --- /dev/null +++ b/apps/hub/src/app/layout.tsx @@ -0,0 +1,15 @@ +import './globals.css'; +import type { ReactNode } from 'react'; + +export const metadata = { + title: 'Kerbal RT — Real-time KSP server', + description: 'A no-warp Kerbal Space Program multiplayer server, mirrored to the web in real time.', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/hub/src/app/page.tsx b/apps/hub/src/app/page.tsx new file mode 100644 index 0000000..aeb2f88 --- /dev/null +++ b/apps/hub/src/app/page.tsx @@ -0,0 +1,61 @@ +import { Card, Pill } from '@kerbal-rt/ui'; + +export default function HomePage() { + return ( +
+
+

Kerbal RT

+ Phase 0 — skeleton +
+ + +

Monorepo skeleton is up. The hub, live-map, and API apps all build and dev-serve.

+

+ Edit src/app/page.tsx in apps/hub and refresh. +

+
+ +
+ + + + + +
+ + +
    +
  1. + Phase 0 (this): monorepo, skeletons, docker-compose, CI. +
  2. +
  3. + Phase 1: KSP telemetry bridge + ingest pipeline. +
  4. +
  5. + Phase 2: Live map 3D scene with time controls. +
  6. +
  7. + Phase 3: Mission calendar, media, crew, YouTube/Twitch embeds. +
  8. +
  9. + Phase 4: Spacenomicon tools. +
  10. +
+
+
+ ); +} diff --git a/apps/hub/tsconfig.json b/apps/hub/tsconfig.json new file mode 100644 index 0000000..874c02a --- /dev/null +++ b/apps/hub/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "jsx": "preserve", + "noEmit": true, + "incremental": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/live-map/index.html b/apps/live-map/index.html new file mode 100644 index 0000000..693f885 --- /dev/null +++ b/apps/live-map/index.html @@ -0,0 +1,12 @@ + + + + + + Kerbal RT — Live Map + + +
+ + + diff --git a/apps/live-map/package.json b/apps/live-map/package.json new file mode 100644 index 0000000..4742594 --- /dev/null +++ b/apps/live-map/package.json @@ -0,0 +1,31 @@ +{ + "name": "@kerbal-rt/live-map", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "3D real-time solar system viewer", + "scripts": { + "dev": "vite --port 3001", + "build": "tsc -p tsconfig.build.json && vite build", + "preview": "vite preview --port 3001", + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "echo 'no tests yet'" + }, + "dependencies": { + "@kerbal-rt/shared-types": "workspace:*", + "@kerbal-rt/orbital-math": "workspace:*", + "@kerbal-rt/ui": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.6" + } +} diff --git a/apps/live-map/src/App.tsx b/apps/live-map/src/App.tsx new file mode 100644 index 0000000..e5523d5 --- /dev/null +++ b/apps/live-map/src/App.tsx @@ -0,0 +1,320 @@ +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { Pill, formatUtAsKspDate } from '@kerbal-rt/ui'; +import { sampleOrbit, positionAt } from '@kerbal-rt/orbital-math'; +import type { CelestialBody } from '@kerbal-rt/shared-types'; + +const API_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? ''; + +/** + * Mock solar system so the scene has something to render before the + * real KSP bridge is wired in (Phase 1). Drop a real snapshot in + * via the same /api/v1/state endpoint and the same renderer will + * work — only `useUniverse()` changes. + */ +function useUniverse() { + const [bodies, setBodies] = useState([]); + const [ut, setUt] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + const mockBodies: CelestialBody[] = [ + makeBody({ + id: 'kerbol', + name: 'Kerbol', + kind: 'star', + radius: 261_600_000, + sphereOfInfluence: Infinity, + mu: 1.172332794e18, + rotationPeriod: 432_000, + axialTilt: 0, + color: '#ffcc33', + parent: null, + sma: 0, + }), + makeBody({ + id: 'kerbin', + name: 'Kerbin', + kind: 'planet', + radius: 600_000, + sphereOfInfluence: 84_159_286, + mu: 3.5316e12, + rotationPeriod: 21_600, + axialTilt: 0, + color: '#3a7d8c', + parent: 'kerbol', + sma: 13_599_840_256, + }), + makeBody({ + id: 'mun', + name: 'Mun', + kind: 'moon', + radius: 200_000, + sphereOfInfluence: 2_429_559, + mu: 6.514e10, + rotationPeriod: 138_984, + axialTilt: 0, + color: '#aaa', + parent: 'kerbin', + sma: 12_000_000, + }), + ]; + setBodies(mockBodies); + setUt(4_700_000); + + // Try to fetch real state if API is reachable + fetch(`${API_URL}/api/v1/state`) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (j?.data?.bodies?.length) { + setBodies(j.data.bodies); + setUt(j.data.ut); + } + }) + .catch((e) => setError(String(e))); + }, []); + + return { bodies, ut, setUt, error }; +} + +function makeBody(opts: { + id: string; + name: string; + kind: CelestialBody['kind']; + radius: number; + sphereOfInfluence: number; + mu: number; + rotationPeriod: number; + axialTilt: number; + color: string; + parent: string | null; + sma: number; +}): CelestialBody { + return { + id: opts.id, + name: opts.name, + kind: opts.kind, + parentId: opts.parent, + radius: opts.radius, + sphereOfInfluence: opts.sphereOfInfluence, + gravitationalParameter: opts.mu, + rotationPeriod: opts.rotationPeriod, + axialTilt: opts.axialTilt, + orbit: { + semiMajorAxis: opts.sma, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: Math.random() * Math.PI * 2, + epoch: 0, + }, + }; +} + +function Scene({ bodies, ut }: { bodies: CelestialBody[]; ut: number }) { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + + const width = mount.clientWidth; + const height = mount.clientHeight; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x000005); + + const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1e12); + camera.position.set(0, 5e9, 1.5e10); + camera.lookAt(0, 0, 0); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + mount.appendChild(renderer.domElement); + + // Lights + scene.add(new THREE.AmbientLight(0x404040, 0.4)); + const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0); + scene.add(sunLight); + + // Build spheres + orbit lines per body + const meshById = new Map(); + const orbitGroup = new THREE.Group(); + scene.add(orbitGroup); + + for (const body of bodies) { + if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) { + // Star: render but skip orbit + if (body.kind === 'star') { + const geo = new THREE.SphereGeometry( + Math.max(body.radius, 1e8), + 32, + 16, + ); + const mat = new THREE.MeshBasicMaterial({ color: 0xffcc33 }); + const mesh = new THREE.Mesh(geo, mat); + scene.add(mesh); + meshById.set(body.id, mesh); + } + continue; + } + + // Sphere — small planets/moons get a minimum size so they're visible + const displayRadius = Math.max(body.radius, 1e6); + const geo = new THREE.SphereGeometry(displayRadius, 32, 16); + const mat = new THREE.MeshPhongMaterial({ + color: bodyColor(body.id), + emissive: 0x111111, + }); + const mesh = new THREE.Mesh(geo, mat); + scene.add(mesh); + meshById.set(body.id, mesh); + + // Orbit line + const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256); + const positions = new Float32Array(points.length * 3); + points.forEach((p, i) => { + positions[i * 3] = p.x; + positions[i * 3 + 1] = p.y; + positions[i * 3 + 2] = p.z; + }); + const lineGeo = new THREE.BufferGeometry(); + lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const lineMat = new THREE.LineBasicMaterial({ + color: bodyColor(body.id), + opacity: 0.6, + transparent: true, + }); + orbitGroup.add(new THREE.LineLoop(lineGeo, lineMat)); + } + + // Resize + const onResize = () => { + if (!mount) return; + const w = mount.clientWidth; + const h = mount.clientHeight; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + }; + window.addEventListener('resize', onResize); + + // Animation loop: place each body at the current propagated position + let raf = 0; + const render = () => { + for (const body of bodies) { + if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) continue; + const parent = bodies.find((b) => b.id === body.parentId); + if (!parent) continue; + const pos = positionAt(body.orbit, parent.gravitationalParameter, ut); + const mesh = meshById.get(body.id); + if (mesh) mesh.position.set(pos.x, pos.y, pos.z); + } + renderer.render(scene, camera); + raf = requestAnimationFrame(render); + }; + render(); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', onResize); + renderer.dispose(); + mount.removeChild(renderer.domElement); + }; + }, [bodies, ut]); + + return
; +} + +function bodyColor(id: string): number { + // Map common body names to colors; default to white + const map: Record = { + kerbol: 0xffcc33, + kerbin: 0x3a7d8c, + mun: 0xaaaaaa, + minmus: 0x997a66, + duna: 0xc46030, + ike: 0x776655, + eve: 0x6b4ea0, + gilly: 0x665544, + jool: 0xa55a2a, + laythe: 0x4a6da0, + vall: 0x665544, + tylo: 0x997a66, + bop: 0x444444, + pol: 0x333333, + moho: 0x664433, + eeloo: 0xeeeeff, + }; + return map[id] ?? 0xffffff; +} + +export function App() { + const { bodies, ut, setUt, error } = useUniverse(); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(60); // 1s of wall = 60s of KSP UT + + useEffect(() => { + if (!playing) return; + const id = setInterval(() => setUt((u) => u + speed), 1000); + return () => clearInterval(id); + }, [playing, speed]); + + return ( +
+ + +
+
+ Kerbal RT + + {error ? 'API offline' : `${bodies.length} bodies`} + +
+
UT {formatUtAsKspDate(ut)}
+
+ + + {[1, 60, 3600, 86400].map((s) => ( + + ))} +
+
+ +
+ Phase 0 skeleton · Three.js +
+
+ ); +} diff --git a/apps/live-map/src/main.tsx b/apps/live-map/src/main.tsx new file mode 100644 index 0000000..d4d12ea --- /dev/null +++ b/apps/live-map/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App.js'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/live-map/src/styles.css b/apps/live-map/src/styles.css new file mode 100644 index 0000000..4b2d1fa --- /dev/null +++ b/apps/live-map/src/styles.css @@ -0,0 +1,31 @@ +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: #000; + color: #e6e6ee; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + overflow: hidden; +} + +button { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #e6e6ee; + padding: 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} + +button:hover { + background: rgba(255, 255, 255, 0.16); +} diff --git a/apps/live-map/src/vite-env.d.ts b/apps/live-map/src/vite-env.d.ts new file mode 100644 index 0000000..c57d674 --- /dev/null +++ b/apps/live-map/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/live-map/tsconfig.build.json b/apps/live-map/tsconfig.build.json new file mode 100644 index 0000000..8869140 --- /dev/null +++ b/apps/live-map/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "include": ["src/**/*"] +} diff --git a/apps/live-map/tsconfig.json b/apps/live-map/tsconfig.json new file mode 100644 index 0000000..5cdda03 --- /dev/null +++ b/apps/live-map/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "jsx": "react-jsx", + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/live-map/tsconfig.node.json b/apps/live-map/tsconfig.node.json new file mode 100644 index 0000000..cbd2a63 --- /dev/null +++ b/apps/live-map/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/live-map/vite.config.ts b/apps/live-map/vite.config.ts new file mode 100644 index 0000000..a1fc73f --- /dev/null +++ b/apps/live-map/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3001, + proxy: { + '/api': { + target: 'http://localhost:4000', + changeOrigin: true, + ws: true, + }, + }, + }, + build: { + target: 'es2022', + sourcemap: true, + }, +}); diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..93fd5eb --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,82 @@ +# Docker Compose for the kerbal-rt data tier. +# +# Phase 0 ships this as a deliverable but doesn't run anything here +# (no Docker available in the dev sandbox). Bring it up with: +# +# docker compose -f infra/docker-compose.yml up -d +# +# Then point the API at it via .env (see .env.example). +# +# Replaces the in-memory StateStore in apps/api/src/state.ts. +# Phase 1 wires Postgres + Timescale (telemetry), Redis (pubsub), +# and MinIO (media) into the API. + +services: + postgres: + image: timescale/timescaledb:2.16.1-pg16 + restart: unless-stopped + environment: + POSTGRES_USER: kerbal + POSTGRES_PASSWORD: kerbal + POSTGRES_DB: kerbal_rt + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init-sql:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kerbal -d kerbal_rt"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7.4-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + minio: + image: minio/minio:RELEASE.2024-09-13T20-26-02Z + restart: unless-stopped + command: ["server", "/data", "--console-address", ":9001"] + environment: + MINIO_ROOT_USER: kerbal + MINIO_ROOT_PASSWORD: kerbal-secret-change-me + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web console + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + + # pgAdmin is convenient in dev; comment out for prod + pgadmin: + image: dpage/pgadmin4:8 + restart: unless-stopped + profiles: ["dev"] + environment: + PGADMIN_DEFAULT_EMAIL: admin@kerbal.local + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - "5050:80" + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres-data: + redis-data: + minio-data: diff --git a/infra/grafana/datasources.yml b/infra/grafana/datasources.yml new file mode 100644 index 0000000..4a3fe2e --- /dev/null +++ b/infra/grafana/datasources.yml @@ -0,0 +1,17 @@ +# Grafana provisioning: auto-load a TimescaleDB datasource on container start. +# Mount this file at /etc/grafana/provisioning/datasources/timescaledb.yml +apiVersion: 1 + +datasources: + - name: TimescaleDB + type: postgres + url: postgres:5432 + database: kerbal_rt + user: kerbal + secureJsonData: + password: kerbal + jsonData: + sslmode: disable + postgresVersion: 1600 + timescaledb: true + isDefault: true diff --git a/infra/init-sql/01-schema.sql b/infra/init-sql/01-schema.sql new file mode 100644 index 0000000..167d6d6 --- /dev/null +++ b/infra/init-sql/01-schema.sql @@ -0,0 +1,84 @@ +-- Schema bootstrap for the kerbal-rt Postgres + Timescale install. +-- Runs automatically on first container start. +-- +-- See kerbalrealtime-clone-plan.md §Component B for the full data model. +-- This is the Phase 0 minimum; Phase 1 fills in the rest. + +CREATE EXTENSION IF NOT EXISTS timescaledb; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Bodies (celestial body catalog; small, mostly static) +CREATE TABLE IF NOT EXISTS bodies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('star','planet','moon','asteroid','comet')), + parent_id TEXT REFERENCES bodies(id), + radius DOUBLE PRECISION NOT NULL, + sphere_of_influence DOUBLE PRECISION NOT NULL, + gravitational_parameter DOUBLE PRECISION NOT NULL, + rotation_period DOUBLE PRECISION NOT NULL, + axial_tilt DOUBLE PRECISION NOT NULL, + elements JSONB NOT NULL +); + +-- Vessels (slowly-changing dimension) +CREATE TABLE IF NOT EXISTS vessels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT, + owner TEXT, + status TEXT NOT NULL CHECK (status IN ('ACTIVE','DECOMMISSIONED','LOST')) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + retired_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS vessels_status_idx ON vessels(status); + +-- Telemetry snapshots (high-volume time series) +CREATE TABLE IF NOT EXISTS telemetry_snapshots ( + ts TIMESTAMPTZ NOT NULL, + ut DOUBLE PRECISION NOT NULL, + payload JSONB NOT NULL +); +SELECT create_hypertable('telemetry_snapshots', 'ts', if_not_exists => TRUE); +CREATE INDEX IF NOT EXISTS telemetry_snapshots_ut_idx ON telemetry_snapshots(ut DESC); + +-- Mission events +CREATE TABLE IF NOT EXISTS mission_events ( + id BIGSERIAL PRIMARY KEY, + mission TEXT NOT NULL, + vessel_id TEXT REFERENCES vessels(id), + title TEXT NOT NULL, + description TEXT, + scheduled_at TIMESTAMPTZ NOT NULL, + ut_at_event DOUBLE PRECISION, + duration_s INTEGER, + kind TEXT NOT NULL CHECK (kind IN ('LAUNCH','BURN','FLYBY','LANDING','DOCKING','EVA','ECLIPSE','OPERATION','OTHER')), + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS mission_events_scheduled_idx ON mission_events(scheduled_at); + +-- Media +CREATE TABLE IF NOT EXISTS media ( + id BIGSERIAL PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('image','youtube')), + url TEXT NOT NULL, + thumbnail_url TEXT, + caption TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + mission_id TEXT, + vessel_id TEXT REFERENCES vessels(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS media_tags_idx ON media USING GIN(tags); +CREATE INDEX IF NOT EXISTS media_created_idx ON media(created_at DESC); + +-- Ground stations +CREATE TABLE IF NOT EXISTS ground_stations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + body_id TEXT NOT NULL REFERENCES bodies(id), + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + alt DOUBLE PRECISION NOT NULL +); diff --git a/infra/init-sql/02-bodies-seed.sql b/infra/init-sql/02-bodies-seed.sql new file mode 100644 index 0000000..c5976f2 --- /dev/null +++ b/infra/init-sql/02-bodies-seed.sql @@ -0,0 +1,37 @@ +-- Seed data: the Kerbol system (KSP stock). +-- Numbers sourced from the KSP wiki and the in-game config files. +-- Gravitational parameter μ in m^3/s^2. +-- This is enough to render the system in the live map before +-- the real telemetry bridge is wired in. + +INSERT INTO bodies (id, name, kind, parent_id, radius, sphere_of_influence, gravitational_parameter, rotation_period, axial_tilt, elements) VALUES + ('kerbol', 'Kerbol', 'star', NULL, 261600000, 'Infinity'::float8::float8, 1.172332794e18, 432000, 0, '{"semiMajorAxis":0,"eccentricity":0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":0,"epoch":0}'), + ('moho', 'Moho', 'planet', 'kerbol', 250000, 9646663, 1.686842e11, 1210000, 0.05, '{"semiMajorAxis":5263138304,"eccentricity":0.2,"inclination":0.075,"longitudeOfAscendingNode":1.5,"argumentOfPeriapsis":2.8,"meanAnomalyAtEpoch":1.0,"epoch":0}'), + ('eve', 'Eve', 'planet', 'kerbol', 700000, 85109365, 8.1717302e12, 80500, 0.1, '{"semiMajorAxis":9832684544,"eccentricity":0.01,"inclination":0.1,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":3.14,"epoch":0}'), + ('kerbin', 'Kerbin', 'planet', 'kerbol', 600000, 84159286, 3.5316e12, 21600, 0, '{"semiMajorAxis":13599840256,"eccentricity":0.05,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":0.7,"epoch":0}'), + ('duna', 'Duna', 'planet', 'kerbol', 320000, 47921949, 3.0136321e11, 65518, 0.06, '{"semiMajorAxis":20726155264,"eccentricity":0.05,"inclination":0.02,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":1.5,"epoch":0}'), + ('dres', 'Dres', 'planet', 'kerbol', 138000, 32832840, 2.1484489e10, 34800, 0.08, '{"semiMajorAxis":40839348203,"eccentricity":0.14,"inclination":0.08,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.1,"epoch":0}'), + ('jool', 'Jool', 'planet', 'kerbol', 6000000, 2450055988, 2.82528e14, 36000, 0.05, '{"semiMajorAxis":68773560320,"eccentricity":0.05,"inclination":0.04,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":4.2,"epoch":0}'), + ('eeloo', 'Eeloo', 'planet', 'kerbol', 210000, 119082940, 7.4410815e10, 19460, 0.1, '{"semiMajorAxis":90118820000,"eccentricity":0.26,"inclination":0.13,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":5.5,"epoch":0}'), + ('gilly', 'Gilly', 'moon', 'eve', 13000, 126123, 8289449.8, 28260, 0.05, '{"semiMajorAxis":31500000,"eccentricity":0.18,"inclination":0.2,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":0.5,"epoch":0}'), + ('mun', 'Mun', 'moon', 'kerbin', 200000, 2429559, 6.514e10, 138984, 0, '{"semiMajorAxis":12000000,"eccentricity":0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":1.0,"epoch":0}'), + ('minmus', 'Minmus', 'moon', 'kerbin', 60000, 2247428, 1.765e9, 40400, 0.04, '{"semiMajorAxis":47000000,"eccentricity":0.0,"inclination":0.075,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.5,"epoch":0}'), + ('ike', 'Ike', 'moon', 'duna', 130000, 1048598, 1.856e9, 65518, 0.05, '{"semiMajorAxis":3200000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":1.5,"epoch":0}'), + ('laythe', 'Laythe', 'moon', 'jool', 500000, 3723645, 1.840e11, 52980, 0, '{"semiMajorAxis":27184000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.0,"epoch":0}'), + ('vall', 'Vall', 'moon', 'jool', 240000, 2406401, 2.061e10, 106200, 0, '{"semiMajorAxis":43152000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.5,"epoch":0}'), + ('tylo', 'Tylo', 'moon', 'jool', 375000, 10856418, 2.122e11, 84600, 0, '{"semiMajorAxis":68500000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":3.0,"epoch":0}'), + ('bop', 'Bop', 'moon', 'jool', 65000, 1220600, 2.486e8, 360, 0.05, '{"semiMajorAxis":128500000,"eccentricity":0.23,"inclination":0.2,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":4.0,"epoch":0}'), + ('pol', 'Pol', 'moon', 'jool', 44000, 1042138, 7.214e7, 340, 0.05, '{"semiMajorAxis":179890000,"eccentricity":0.17,"inclination":0.15,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":4.5,"epoch":0}') +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + radius = EXCLUDED.radius, + sphere_of_influence = EXCLUDED.sphere_of_influence, + gravitational_parameter = EXCLUDED.gravitational_parameter, + rotation_period = EXCLUDED.rotation_period, + axial_tilt = EXCLUDED.axial_tilt, + elements = EXCLUDED.elements; + +-- Sample ground station: the project's "Montana DSN" (real site has this). +INSERT INTO ground_stations (id, name, body_id, lat, lon, alt) VALUES + ('montana', 'Montana DSN', 'kerbin', 47.0, -110.0, 1200) +ON CONFLICT (id) DO NOTHING; diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf new file mode 100644 index 0000000..ce2453d --- /dev/null +++ b/infra/nginx/nginx.conf @@ -0,0 +1,64 @@ +# nginx config for production. Phase 0 ships as deliverable; deploy in Phase 6. +# +# Routes: +# / -> hub (Next.js) on :3000 +# /live-map/ -> live-map (Vite preview) on :3001 +# /api/ -> API (Hono) on :4000 +# /ws -> API WebSocket upgrade for /api/v1/live + +upstream hub { + server 127.0.0.1:3000; +} +upstream map { + server 127.0.0.1:3001; +} +upstream api { + server 127.0.0.1:4000; +} + +server { + listen 80; + server_name _; + + client_max_body_size 25m; + + # Hub + location / { + proxy_pass http://hub; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Live map (mounted under /live-map/ so assets resolve) + location /live-map/ { + proxy_pass http://map/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # API + WebSocket + location /api/ { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; # keep WebSockets open + } +} + +# Map for WebSocket connection upgrade +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..08a7866 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "kerbal-rt", + "version": "0.1.0", + "private": true, + "description": "Real-time KSP server status hub + live 3D universe map", + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "pnpm -r --parallel --filter=./apps/* dev", + "dev:hub": "pnpm --filter @kerbal-rt/hub dev", + "dev:map": "pnpm --filter @kerbal-rt/live-map dev", + "dev:api": "pnpm --filter @kerbal-rt/api dev", + "build": "pnpm -r --filter=./packages/* build && pnpm -r --filter=./apps/* build", + "test": "pnpm -r test", + "test:watch": "pnpm -r --parallel test:watch", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,css,yml,yaml}\" --ignore-path .gitignore", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,css,yml,yaml}\" --ignore-path .gitignore", + "clean": "pnpm -r exec rm -rf node_modules dist .next .turbo && rm -rf node_modules" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "prettier": "^3.3.3", + "typescript": "^5.6.2" + } +} diff --git a/packages/orbital-math/package.json b/packages/orbital-math/package.json new file mode 100644 index 0000000..a5c3090 --- /dev/null +++ b/packages/orbital-math/package.json @@ -0,0 +1,26 @@ +{ + "name": "@kerbal-rt/orbital-math", + "version": "0.1.0", + "private": true, + "description": "Pure functions for keplerian orbit propagation, occultation, transfer windows", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "vitest run", + "test:watch": "vitest", + "build": "tsc" + }, + "dependencies": { + "@kerbal-rt/shared-types": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.6.2", + "vitest": "^2.1.1" + } +} diff --git a/packages/orbital-math/src/index.ts b/packages/orbital-math/src/index.ts new file mode 100644 index 0000000..89b0fe8 --- /dev/null +++ b/packages/orbital-math/src/index.ts @@ -0,0 +1,9 @@ +export { solveKepler } from './kepler.js'; +export { + meanMotion, + propagateToEpoch, + positionAt, + sampleOrbit, +} from './propagate.js'; +export { shadowFraction } from './occultation.js'; +export { phaseAngle, hohmannDeltaV, findTransferWindows } from './transfer.js'; diff --git a/packages/orbital-math/src/kepler.ts b/packages/orbital-math/src/kepler.ts new file mode 100644 index 0000000..9853791 --- /dev/null +++ b/packages/orbital-math/src/kepler.ts @@ -0,0 +1,28 @@ +/** + * Solve Kepler's equation: M = E - e * sin(E) + * for the eccentric anomaly E, given mean anomaly M and eccentricity e. + * + * Uses Newton-Raphson iteration with a sane initial guess. + * Converges in ~5 iterations for any reasonable e (< 0.9). + * For near-parabolic / hyperbolic orbits, use a different solver. + */ +export function solveKepler(meanAnomaly: number, eccentricity: number): number { + // Normalize M to [-π, π] for faster convergence. + const TWO_PI = Math.PI * 2; + let M = meanAnomaly % TWO_PI; + if (M > Math.PI) M -= TWO_PI; + if (M < -Math.PI) M += TWO_PI; + + // Initial guess: E₀ = M + e·sin(M) is a good first order approximation. + let E = M + eccentricity * Math.sin(M); + + for (let i = 0; i < 30; i++) { + const f = E - eccentricity * Math.sin(E) - M; + const fp = 1 - eccentricity * Math.cos(E); + const dE = f / fp; + E -= dE; + if (Math.abs(dE) < 1e-12) break; + } + + return E; +} diff --git a/packages/orbital-math/src/occultation.ts b/packages/orbital-math/src/occultation.ts new file mode 100644 index 0000000..c231386 --- /dev/null +++ b/packages/orbital-math/src/occultation.ts @@ -0,0 +1,53 @@ +/** + * Geometric occultation: given a position (relative to a body's center) + * and the radii of the occluder (R1) and the body the observer is on (R2), + * is the observer currently in shadow? + * + * Used for both: + * - "is this vessel in the planet's shadow?" (R1 = planet radius, R2 ≈ 0) + * - "is this ground station blocked by the local terrain?" (R1 = planet, R2 = earth station) + * + * Returns the fraction (0..1) of the line of sight to the sun that is + * occluded. 0 = full sun, 1 = total eclipse. + * + * Note: the canonical way to do this is to compute the half-angle between + * the sun and the occluding body as seen by the observer. We treat the + * sun as effectively at infinity (parallel rays) which is fine for KSP + * since Kerbol is the system root and we're never going to need parallax + * precision at this scale. + */ +export function shadowFraction( + observerToSun: { x: number; y: number; z: number }, + occluderToObserver: { x: number; y: number; z: number }, + occluderRadius: number, +): number { + // Vector from observer to sun, normalized + const sunDist = Math.hypot(observerToSun.x, observerToSun.y, observerToSun.z); + if (sunDist === 0) return 0; + const sx = observerToSun.x / sunDist; + const sy = observerToSun.y / sunDist; + const sz = observerToSun.z / sunDist; + + // Project occluder center onto the sun-direction line + const proj = occluderToObserver.x * sx + occluderToObserver.y * sy + occluderToObserver.z * sz; + if (proj >= 0) { + // Occluder is behind the observer relative to the sun → no eclipse + return 0; + } + // Perpendicular distance from occluder center to sun ray + const px = occluderToObserver.x - proj * sx; + const py = occluderToObserver.y - proj * sy; + const pz = occluderToObserver.z - proj * sz; + const perpDist = Math.hypot(px, py, pz); + + if (perpDist >= occluderRadius) return 0; + // Approximate chord length through the occluder disc + const halfChord = Math.sqrt(occluderRadius * occluderRadius - perpDist * perpDist); + // Approximate the angular size of the sun as seen from the occluder + // vs the angular size of the occluder; we use 1.0 for the sun + // (i.e. effectively point source) — good enough for visualization. + // For a "fraction in shadow" treat the occluder disc as fully shadowing + // when perpDist + halfChord reaches the observer; that simplifies to + // perpDist < occluderRadius which we already check. + return Math.min(1, 1 - perpDist / occluderRadius); +} diff --git a/packages/orbital-math/src/propagate.ts b/packages/orbital-math/src/propagate.ts new file mode 100644 index 0000000..381fd7d --- /dev/null +++ b/packages/orbital-math/src/propagate.ts @@ -0,0 +1,141 @@ +import type { KeplerianElements, CelestialBody } from '@kerbal-rt/shared-types'; +import { solveKepler } from './kepler.js'; + +const TWO_PI = Math.PI * 2; + +/** + * Compute the mean motion n (rad/s) from semi-major axis and the + * gravitational parameter μ of the central body. + * + * n = sqrt(μ / a^3) + */ +export function meanMotion(semiMajorAxis: number, mu: number): number { + return Math.sqrt(mu / Math.pow(semiMajorAxis, 3)); +} + +/** + * Propagate Keplerian elements forward in time to a new epoch. + * + * Only `meanAnomalyAtEpoch` and `epoch` change. (For a fully accurate + * J2-perturbed propagation you'd also adjust RAAN and argPe due to + * precession, but for visualization purposes this is plenty.) + */ +export function propagateToEpoch( + elements: KeplerianElements, + mu: number, + newEpoch: number, +): KeplerianElements { + const dt = newEpoch - elements.epoch; + const n = meanMotion(elements.semiMajorAxis, mu); + return { + ...elements, + epoch: newEpoch, + meanAnomalyAtEpoch: elements.meanAnomalyAtEpoch + n * dt, + }; +} + +/** + * Propagate an orbit to a target UT and return the position in the + * parent body's inertial frame. + * + * For an ellipse (e < 1): + * 1. Compute mean anomaly at target time + * 2. Solve Kepler for eccentric anomaly E + * 3. Position in perifocal frame: (a(cosE - e), a√(1-e²) sinE, 0) + * 4. Rotate by ω, i, Ω to get the inertial frame + * + * Returns the cartesian position vector (m). + */ +export function positionAt( + elements: KeplerianElements, + mu: number, + ut: number, +): { x: number; y: number; z: number } { + const e = elements.eccentricity; + const a = elements.semiMajorAxis; + const i = elements.inclination; + const O = elements.longitudeOfAscendingNode; // RAAN + const w = elements.argumentOfPeriapsis; + + // Mean anomaly at the requested time + const n = meanMotion(a, mu); + const M = elements.meanAnomalyAtEpoch + n * (ut - elements.epoch); + + // Eccentric anomaly + const E = solveKepler(M, e); + + // True anomaly + // cos ν = (cos E - e) / (1 - e cos E) + // sin ν = (√(1-e²) sin E) / (1 - e cos E) + const cosE = Math.cos(E); + const sinE = Math.sin(E); + const denom = 1 - e * cosE; + const cosNu = (cosE - e) / denom; + const sinNu = (Math.sqrt(1 - e * e) * sinE) / denom; + + // Distance + const r = a * (1 - e * cosE); + + // Position in perifocal frame (P, Q, W) + const xP = r * cosNu; + const yQ = r * sinNu; + + // Rotation matrix from perifocal to inertial: Rz(-Ω) · Rx(-i) · Rz(-ω) + // Applied to (xP, yQ, 0): + const cosO = Math.cos(O); + const sinO = Math.sin(O); + const cosi = Math.cos(i); + const sini = Math.sin(i); + const cosw = Math.cos(w); + const sinw = Math.sin(w); + + const x = + (cosO * cosw - sinO * sinw * cosi) * xP + (-cosO * sinw - sinO * cosw * cosi) * yQ; + const y = + (sinO * cosw + cosO * sinw * cosi) * xP + (-sinO * sinw + cosO * cosw * cosi) * yQ; + const z = sinw * sini * xP + cosw * sini * yQ; + + return { x, y, z }; +} + +/** + * Sample a full orbit as a list of cartesian points. Useful for drawing + * the orbit line. Returns `steps` evenly-spaced points in true anomaly + * around the conic. + */ +export function sampleOrbit( + elements: KeplerianElements, + mu: number, + steps: number = 128, +): { x: number; y: number; z: number }[] { + const points: { x: number; y: number; z: number }[] = []; + const e = elements.eccentricity; + const a = elements.semiMajorAxis; + const i = elements.inclination; + const O = elements.longitudeOfAscendingNode; + const w = elements.argumentOfPeriapsis; + + const cosO = Math.cos(O); + const sinO = Math.sin(O); + const cosi = Math.cos(i); + const sini = Math.sin(i); + const cosw = Math.cos(w); + const sinw = Math.sin(w); + + for (let k = 0; k < steps; k++) { + // True anomaly uniformly from 0 to 2π + const nu = (k / steps) * TWO_PI; + const cosNu = Math.cos(nu); + const sinNu = Math.sin(nu); + const r = (a * (1 - e * e)) / (1 + e * cosNu); + const xP = r * cosNu; + const yQ = r * sinNu; + const x = + (cosO * cosw - sinO * sinw * cosi) * xP + (-cosO * sinw - sinO * cosw * cosi) * yQ; + const y = + (sinO * cosw + cosO * sinw * cosi) * xP + (-sinO * sinw + cosO * cosw * cosi) * yQ; + const z = sinw * sini * xP + cosw * sini * yQ; + points.push({ x, y, z }); + } + return points; +} diff --git a/packages/orbital-math/src/transfer.ts b/packages/orbital-math/src/transfer.ts new file mode 100644 index 0000000..4b820ac --- /dev/null +++ b/packages/orbital-math/src/transfer.ts @@ -0,0 +1,95 @@ +import type { KeplerianElements, CelestialBody } from '@kerbal-rt/shared-types'; +import { meanMotion } from './propagate.js'; + +const TWO_PI = Math.PI * 2; + +/** + * Compute the phase angle between two orbiting bodies at a given UT. + * Phase angle is the angle at the central body between the two bodies, + * measured in the direction of motion. + */ +export function phaseAngle( + a: KeplerianElements, + aMu: number, + b: KeplerianElements, + bMu: number, + ut: number, +): number { + const nA = meanMotion(a.semiMajorAxis, aMu); + const nB = meanMotion(b.semiMajorAxis, bMu); + const MA = a.meanAnomalyAtEpoch + nA * (ut - a.epoch); + const MB = b.meanAnomalyAtEpoch + nB * (ut - b.epoch); + let phase = MB - MA; + // Normalize to [0, 2π) + phase = phase % TWO_PI; + if (phase < 0) phase += TWO_PI; + return phase; +} + +/** + * Very rough Hohmann transfer Δv estimate between two coplanar circular + * orbits. Good enough for a "transfer window" calculator; for precise + * numbers you'd solve Lambert's problem. + * + * Returns the total Δv (m/s) for the two-burn transfer. + */ +export function hohmannDeltaV( + r1: number, + r2: number, + mu: number, +): { dv1: number; dv2: number; total: number; transferTime: number } { + // Semi-major axis of transfer ellipse + const aTransfer = (r1 + r2) / 2; + // Circular velocities + const v1 = Math.sqrt(mu / r1); + const v2 = Math.sqrt(mu / r2); + // Velocities on transfer ellipse at periapsis (r1) and apoapsis (r2) + const vt1 = Math.sqrt(mu * (2 / r1 - 1 / aTransfer)); + const vt2 = Math.sqrt(mu * (2 / r2 - 1 / aTransfer)); + const dv1 = Math.abs(vt1 - v1); + const dv2 = Math.abs(v2 - vt2); + const transferTime = Math.PI * Math.sqrt(Math.pow(aTransfer, 3) / mu); + return { dv1, dv2, total: dv1 + dv2, transferTime }; +} + +/** + * Find the next N transfer windows from `from` body to `to` body, + * defined as times when the phase angle is within ±tolerance of the + * ideal Hohmann transfer phase. + * + * Ideal phase = π · (1 - (1/2) · ((r2/r1)^(3/2) + 1)^(-2/3)) + * (this is the classic approximation for the case r2 > r1) + * + * We just scan forward from `ut` until we've found `n` windows. + */ +export function findTransferWindows( + from: { elements: KeplerianElements; mu: number }, + to: { elements: KeplerianElements; mu: number }, + ut: number, + options: { count?: number; toleranceRad?: number; maxSearchTime?: number } = {}, +): { ut: number; phaseAngle: number }[] { + const { count = 3, toleranceRad = 0.15, maxSearchTime = 5 * 365 * 24 * 3600 } = options; + const r1 = from.elements.semiMajorAxis; + const r2 = to.elements.semiMajorAxis; + const ratio = Math.pow(r2 / r1, 1.5); + const idealPhase = Math.PI * (1 - Math.pow(1 + ratio, -2 / 3) * 0.5); + // For inner→outer transfers; for outer→inner the phase wraps differently. + // For simplicity, accept either ±(2π - idealPhase) too. + const phases = [idealPhase, TWO_PI - idealPhase]; + + const results: { ut: number; phaseAngle: number }[] = []; + const stepSec = 3600; // 1h steps for the coarse scan + const nA = meanMotion(from.elements.semiMajorAxis, from.mu); + + for (let t = ut; t < ut + maxSearchTime && results.length < count; t += stepSec) { + const phase = phaseAngle(from.elements, from.mu, to.elements, to.mu, t); + for (const target of phases) { + const diff = Math.abs(((phase - target + Math.PI) % TWO_PI) - Math.PI); + if (diff < toleranceRad) { + results.push({ ut: t, phaseAngle: phase }); + break; + } + } + } + return results; +} diff --git a/packages/orbital-math/tests/kepler.test.ts b/packages/orbital-math/tests/kepler.test.ts new file mode 100644 index 0000000..8eb352b --- /dev/null +++ b/packages/orbital-math/tests/kepler.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { solveKepler } from '../src/kepler.js'; + +describe('solveKepler', () => { + it('returns 0 for M=0, e=0', () => { + expect(solveKepler(0, 0)).toBeCloseTo(0, 10); + }); + + it('returns M for e=0 (circular orbit)', () => { + const M = 1.234; + expect(solveKepler(M, 0)).toBeCloseTo(M, 10); + }); + + it('solves low-eccentricity orbits', () => { + // E ≈ M + e·sin(M) is the first-order correction + const e = 0.1; + const M = 0.5; + const E = solveKepler(M, e); + const residual = E - e * Math.sin(E) - M; + expect(residual).toBeCloseTo(0, 10); + }); + + it('solves high-eccentricity orbits (e=0.9)', () => { + const e = 0.9; + const M = 1.0; + const E = solveKepler(M, e); + const residual = E - e * Math.sin(E) - M; + expect(residual).toBeCloseTo(0, 10); + }); + + it('normalizes M to [-π, π]', () => { + // 3π and π are equivalent; should give the same E. + const e = 0.3; + expect(solveKepler(3 * Math.PI, e)).toBeCloseTo(solveKepler(Math.PI, e), 10); + }); +}); diff --git a/packages/orbital-math/tsconfig.json b/packages/orbital-math/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/orbital-math/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/orbital-math/vitest.config.ts b/packages/orbital-math/vitest.config.ts new file mode 100644 index 0000000..b0e95a5 --- /dev/null +++ b/packages/orbital-math/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..6506e04 --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,24 @@ +{ + "name": "@kerbal-rt/shared-types", + "version": "0.1.0", + "private": true, + "description": "TypeScript types shared between API, hub, and live-map", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "echo 'no tests yet'", + "build": "tsc" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.6.2" + } +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts new file mode 100644 index 0000000..ea858f0 --- /dev/null +++ b/packages/shared-types/src/index.ts @@ -0,0 +1,203 @@ +/** + * Shared types used across the API, hub, and live-map frontends. + * + * Everything that crosses an HTTP or WebSocket boundary lives here. + * Nothing in this package should import from anywhere else in the + * monorepo — keep it a leaf. + */ + +// ─── Celestial bodies ────────────────────────────────────────────────────── + +export type BodyKind = 'star' | 'planet' | 'moon' | 'asteroid' | 'comet'; + +/** + * Keplerian orbital elements at a given epoch. + * Angles are in radians, distances in meters. + */ +export interface KeplerianElements { + /** Semi-major axis (m) */ + semiMajorAxis: number; + /** Eccentricity (0=circle, 0..1=ellipse, 1+=parabolic/hyperbolic) */ + eccentricity: number; + /** Inclination (rad) */ + inclination: number; + /** Longitude of ascending node (rad) */ + longitudeOfAscendingNode: number; + /** Argument of periapsis (rad) */ + argumentOfPeriapsis: number; + /** Mean anomaly at epoch (rad) */ + meanAnomalyAtEpoch: number; + /** Epoch (UT seconds) */ + epoch: number; +} + +export interface CelestialBody { + id: string; + name: string; + kind: BodyKind; + parentId: string | null; + radius: number; // m + /** Sphere of influence (m); 0 or Infinity for the system root (the star) */ + sphereOfInfluence: number; + /** Gravitational parameter μ = G·M (m^3/s^2) */ + gravitationalParameter: number; + /** Rotation period (s); 0 for tidally-locked or N/A */ + rotationPeriod: number; + /** Axial tilt (rad) */ + axialTilt: number; + /** Keplerian elements relative to the parent body */ + orbit: KeplerianElements; +} + +// ─── Vessels ─────────────────────────────────────────────────────────────── + +export type VesselSituation = + | 'ORBITING' + | 'ESCAPING' + | 'LANDED' + | 'SPLASHED' + | 'PRELAUNCH' + | 'FLYING' + | 'SUB_ORBITAL' + | 'DOCKED' + | 'UNKNOWN'; + +export type VesselStatus = 'ACTIVE' | 'DECOMMISSIONED' | 'LOST'; + +export interface Vessel { + id: string; + name: string; + type: string | null; // e.g. "Probe", "Booster", "Station" + owner: string | null; // e.g. "KASA", "SPES" + situation: VesselSituation; + status: VesselStatus; + /** Keplerian elements relative to the body the vessel is currently orbiting */ + orbit: KeplerianElements; + /** ID of the body the vessel's orbit is currently referenced to */ + referenceBodyId: string; + createdAt: string; // ISO 8601 + retiredAt: string | null; +} + +export interface GroundStation { + id: string; + name: string; + bodyId: string; + lat: number; // deg + lon: number; // deg + alt: number; // m above sea level +} + +// ─── State snapshots ─────────────────────────────────────────────────────── + +/** + * A full universe snapshot, published by the KSP telemetry bridge + * on each tick. Frontends use this to render the live map and to + * maintain client-side state. + */ +export interface UniverseSnapshot { + /** KSP universal time, seconds from epoch 0 (year 1, day 1) */ + ut: number; + /** Wall-clock time when this snapshot was captured (ISO 8601) */ + capturedAt: string; + activeVesselId: string | null; + bodies: CelestialBody[]; + vessels: Vessel[]; + groundStations: GroundStation[]; +} + +// ─── Missions & events ───────────────────────────────────────────────────── + +export type MissionEventKind = + | 'LAUNCH' + | 'BURN' + | 'FLYBY' + | 'LANDING' + | 'DOCKING' + | 'EVA' + | 'ECLIPSE' + | 'OPERATION' + | 'OTHER'; + +export interface MissionEvent { + id: number; + mission: string; + vesselId: string | null; + title: string; + description: string | null; + scheduledAt: string; // ISO 8601 wall-clock + utAtEvent: number | null; // KSP UT at the scheduled time, if known + durationSeconds: number | null; + kind: MissionEventKind; + createdBy: string | null; + createdAt: string; +} + +export interface Mission { + id: string; + name: string; + agency: string; // "KASA" | "SPES" | other + description: string | null; + startedAt: string; + endedAt: string | null; + vesselIds: string[]; +} + +// ─── Media ───────────────────────────────────────────────────────────────── + +export type MediaKind = 'image' | 'youtube'; + +export interface MediaItem { + id: number; + kind: MediaKind; + url: string; + thumbnailUrl: string | null; + caption: string | null; + tags: string[]; + missionId: string | null; + vesselId: string | null; + createdAt: string; +} + +// ─── API envelope ────────────────────────────────────────────────────────── + +export interface ApiError { + error: true; + code: string; + message: string; + details?: unknown; +} + +export interface ApiOk { + error: false; + data: T; +} + +export type ApiResponse = ApiOk | ApiError; + +export * from './schemas.js'; + +// ─── WebSocket protocol ──────────────────────────────────────────────────── + +/** + * Messages pushed from the API to live-map / hub clients over `/api/v1/live`. + * Clients should ignore unknown message types (forward-compat). + */ +export type LiveMessage = + | { type: 'snapshot'; snapshot: UniverseSnapshot } + | { type: 'vessel_added'; vessel: Vessel } + | { type: 'vessel_updated'; vessel: Vessel } + | { type: 'vessel_removed'; vesselId: string } + | { type: 'event_new'; event: MissionEvent } + | { type: 'event_updated'; event: MissionEvent } + | { type: 'event_removed'; eventId: number } + | { type: 'mission_update'; mission: Mission } + | { type: 'ping'; serverTime: string }; + +// ─── Constants ───────────────────────────────────────────────────────────── + +/** The KSP epoch: year 1, day 1, 00:00:00 UT, in seconds. */ +export const KSP_EPOCH_UT = 0; + +/** Default polling cadence for clients that don't open a WebSocket. */ +export const DEFAULT_SNAPSHOT_TTL_SECONDS = 30; diff --git a/packages/shared-types/src/schemas.ts b/packages/shared-types/src/schemas.ts new file mode 100644 index 0000000..2a93870 --- /dev/null +++ b/packages/shared-types/src/schemas.ts @@ -0,0 +1,111 @@ +/** + * Zod schemas for runtime validation of API payloads. These mirror the + * TypeScript types in `index.ts` and are re-exported so the API can + * validate ingest payloads and the frontends can validate responses + * if they want stronger guarantees. + */ +import { z } from 'zod'; + +export const KeplerianElementsSchema = z.object({ + semiMajorAxis: z.number().nonnegative(), + eccentricity: z.number().min(0), + inclination: z.number(), + longitudeOfAscendingNode: z.number(), + argumentOfPeriapsis: z.number(), + meanAnomalyAtEpoch: z.number(), + epoch: z.number(), +}); + +export const CelestialBodySchema = z.object({ + id: z.string(), + name: z.string(), + kind: z.enum(['star', 'planet', 'moon', 'asteroid', 'comet']), + parentId: z.string().nullable(), + radius: z.number().nonnegative(), + sphereOfInfluence: z.number().nonnegative(), + gravitationalParameter: z.number().nonnegative(), + rotationPeriod: z.number().nonnegative(), + axialTilt: z.number(), + orbit: KeplerianElementsSchema, +}); + +export const VesselSituationSchema = z.enum([ + 'ORBITING', + 'ESCAPING', + 'LANDED', + 'SPLASHED', + 'PRELAUNCH', + 'FLYING', + 'SUB_ORBITAL', + 'DOCKED', + 'UNKNOWN', +]); + +export const VesselStatusSchema = z.enum(['ACTIVE', 'DECOMMISSIONED', 'LOST']); + +export const VesselSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string().nullable(), + owner: z.string().nullable(), + situation: VesselSituationSchema, + status: VesselStatusSchema, + orbit: KeplerianElementsSchema, + referenceBodyId: z.string(), + createdAt: z.string(), + retiredAt: z.string().nullable(), +}); + +export const GroundStationSchema = z.object({ + id: z.string(), + name: z.string(), + bodyId: z.string(), + lat: z.number(), + lon: z.number(), + alt: z.number(), +}); + +export const UniverseSnapshotSchema = z.object({ + ut: z.number(), + capturedAt: z.string(), + activeVesselId: z.string().nullable(), + bodies: z.array(CelestialBodySchema), + vessels: z.array(VesselSchema), + groundStations: z.array(GroundStationSchema), +}); + +export const MissionEventSchema = z.object({ + id: z.number().int().positive(), + mission: z.string(), + vesselId: z.string().nullable(), + title: z.string(), + description: z.string().nullable(), + scheduledAt: z.string(), + utAtEvent: z.number().nullable(), + durationSeconds: z.number().int().nullable(), + kind: z.enum([ + 'LAUNCH', + 'BURN', + 'FLYBY', + 'LANDING', + 'DOCKING', + 'EVA', + 'ECLIPSE', + 'OPERATION', + 'OTHER', + ]), + createdBy: z.string().nullable(), + createdAt: z.string(), +}); + +export const MediaItemSchema = z.object({ + id: z.number().int().positive(), + kind: z.enum(['image', 'youtube']), + url: z.string().url(), + thumbnailUrl: z.string().url().nullable(), + caption: z.string().nullable(), + tags: z.array(z.string()), + missionId: z.string().nullable(), + vesselId: z.string().nullable(), + createdAt: z.string(), +}); diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..9cc1388 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@kerbal-rt/ui", + "version": "0.1.0", + "private": true, + "description": "Shared React components for hub + live-map", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "echo 'no linter yet'", + "test": "echo 'no tests yet'", + "build": "tsc" + }, + "peerDependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "typescript": "^5.6.2" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..062a2cd --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,64 @@ +/** + * Shared UI primitives used by both hub and live-map frontends. + * Keep this package dependency-free (no Tailwind, no MUI) — apps + * style with their own CSS / framework. + */ + +import { type ReactNode } from 'react'; + +export function Pill({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'success' | 'warn' | 'danger' }) { + const colorMap = { + neutral: 'var(--pill-neutral, #ddd)', + success: 'var(--pill-success, #2c5)', + warn: 'var(--pill-warn, #fa3)', + danger: 'var(--pill-danger, #e44)', + } as const; + return ( + + {children} + + ); +} + +export function Card({ children, title }: { children: ReactNode; title?: string }) { + return ( +
+ {title && ( +

{title}

+ )} + {children} +
+ ); +} + +export function formatUtAsKspDate(ut: number): string { + // KSP year 1 day 1 00:00:00 is UT=0. A KSP day is 6 hours, a year is 426 days. + const KSP_DAY_SECONDS = 6 * 3600; + const KSP_YEAR_DAYS = 426; + const totalDays = ut / KSP_DAY_SECONDS; + const year = Math.floor(totalDays / KSP_YEAR_DAYS) + 1; + const day = (Math.floor(totalDays) % KSP_YEAR_DAYS) + 1; + const secondsInDay = ut % KSP_DAY_SECONDS; + const hour = Math.floor(secondsInDay / 3600); + const minute = Math.floor((secondsInDay % 3600) / 60); + const second = Math.floor(secondsInDay % 60); + return `Y${year} D${day} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`; +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..aa28f57 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..5d31d69 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Bring the full stack up locally. Assumes Docker is installed. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + cp .env.example .env + echo "Created .env from .env.example — review and edit before proceeding." +fi + +echo "→ starting data services (Postgres+Timescale, Redis, MinIO)…" +docker compose -f infra/docker-compose.yml up -d + +echo "→ installing workspace deps…" +pnpm install + +echo "→ running dev servers in parallel…" +echo " hub: http://localhost:3000" +echo " live-map: http://localhost:3001" +echo " api: http://localhost:4000" +echo +exec pnpm dev diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..3521efa --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "exclude": ["node_modules", "dist", "build", ".next", ".turbo"] +}