Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)

This commit is contained in:
Mavis
2026-06-02 15:47:28 +00:00
commit 7b19c54943
59 changed files with 2391 additions and 0 deletions
+320
View File
@@ -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>
);
}
+10
View File
@@ -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>,
);
+31
View File
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}