Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)
This commit is contained in:
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
+47
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof buildApp>;
|
||||||
@@ -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);
|
||||||
@@ -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<string, Vessel> = new Map();
|
||||||
|
private bodiesById: Map<string, CelestialBody> = 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();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Test-only Hono app export. Doesn't bind a port.
|
||||||
|
*/
|
||||||
|
import { buildApp } from './app.js';
|
||||||
|
export const app = buildApp();
|
||||||
@@ -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<WebSocket>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Card, Pill } from '@kerbal-rt/ui';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 960, margin: '0 auto', padding: '2rem 1rem' }}>
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Kerbal RT</h1>
|
||||||
|
<Pill tone="success">Phase 0 — skeleton</Pill>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card title="Status">
|
||||||
|
<p>Monorepo skeleton is up. The hub, live-map, and API apps all build and dev-serve.</p>
|
||||||
|
<p>
|
||||||
|
Edit <code>src/app/page.tsx</code> in <code>apps/hub</code> and refresh.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ height: '1rem' }} />
|
||||||
|
|
||||||
|
<Card title="Quick links">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/live-map">Live map</a> (scaffolded in the same Vite app)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="http://localhost:4000/health" target="_blank" rel="noreferrer">
|
||||||
|
API health
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com" target="_blank" rel="noreferrer">
|
||||||
|
Repo
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ height: '1rem' }} />
|
||||||
|
|
||||||
|
<Card title="Phases">
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<strong>Phase 0 (this):</strong> monorepo, skeletons, docker-compose, CI.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Phase 1:</strong> KSP telemetry bridge + ingest pipeline.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Phase 2:</strong> Live map 3D scene with time controls.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Phase 3:</strong> Mission calendar, media, crew, YouTube/Twitch embeds.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Phase 4:</strong> Spacenomicon tools.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Kerbal RT — Live Map</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CelestialBody[]>([]);
|
||||||
|
const [ut, setUt] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(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<HTMLDivElement>(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<string, THREE.Mesh>();
|
||||||
|
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 <div ref={mountRef} style={{ width: '100%', height: '100%' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyColor(id: string): number {
|
||||||
|
// Map common body names to colors; default to white
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
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 (
|
||||||
|
<div style={{ width: '100vw', height: '100vh', position: 'relative', background: '#000' }}>
|
||||||
|
<Scene bodies={bodies} ut={ut} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<strong>Kerbal RT</strong>
|
||||||
|
<Pill tone={error ? 'danger' : 'success'}>
|
||||||
|
{error ? 'API offline' : `${bodies.length} bodies`}
|
||||||
|
</Pill>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>UT {formatUtAsKspDate(ut)}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginTop: 8 }}>
|
||||||
|
<button onClick={() => setPlaying((p) => !p)}>
|
||||||
|
{playing ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setUt(0)}>Reset</button>
|
||||||
|
{[1, 60, 3600, 86400].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSpeed(s)}
|
||||||
|
style={{ fontWeight: speed === s ? 'bold' : 'normal' }}
|
||||||
|
>
|
||||||
|
×{s < 60 ? s : s < 3600 ? `${s / 60}m` : `${s / 3600}h`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Phase 0 skeleton · Three.js
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
error: false;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponse<T> = ApiOk<T> | 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;
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.125rem 0.5rem',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: colorMap[tone],
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, title }: { children: ReactNode; title?: string }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--card-border, #333)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'var(--card-bg, rgba(255,255,255,0.03))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1rem', fontWeight: 600 }}>{title}</h3>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
Executable
+22
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user