Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user