Merge pull request 'Phase 2: 3D live map driven by API WebSocket' (#2) from phase-2 into main
CI / Lint, typecheck, test, build (push) Failing after 9s
CI / Lint, typecheck, test, build (push) Failing after 9s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"preview": "vite preview --port 3001",
|
"preview": "vite preview --port 3001",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "echo 'no linter yet'",
|
"lint": "echo 'no linter yet'",
|
||||||
"test": "echo 'no tests yet'"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kerbal-rt/shared-types": "workspace:*",
|
"@kerbal-rt/shared-types": "workspace:*",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"@types/three": "^0.169.0",
|
"@types/three": "^0.169.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.6"
|
"vite": "^5.4.6",
|
||||||
|
"vitest": "^2.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-284
@@ -1,313 +1,226 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
/**
|
||||||
import * as THREE from 'three';
|
* App — top-level live map.
|
||||||
import { Pill, formatUtAsKspDate } from '@kerbal-rt/ui';
|
*
|
||||||
import { sampleOrbit, positionAt } from '@kerbal-rt/orbital-math';
|
* Subscribes to the API WebSocket, manages the simulation time
|
||||||
import type { CelestialBody } from '@kerbal-rt/shared-types';
|
* (UT), and renders the 3D scene plus all the UI panels.
|
||||||
|
*
|
||||||
|
* Time model:
|
||||||
|
* - `liveUt`: the UT of the most recent snapshot received
|
||||||
|
* - `displayUt`: what the scene is currently showing. Starts at
|
||||||
|
* liveUt, advances on play, and can be scrubbed via the slider.
|
||||||
|
* - When `displayUt === liveUt`, the pill shows "LIVE".
|
||||||
|
* When the user scrubs backwards or speed-0 pauses, the pill
|
||||||
|
* shows how far behind/ahead we are.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Scene } from './scene/Scene.js';
|
||||||
|
import { TimeControls } from './panels/TimeControls.js';
|
||||||
|
import { VesselList } from './panels/VesselList.js';
|
||||||
|
import { FocusPanel } from './panels/FocusPanel.js';
|
||||||
|
import { StatusPill } from './panels/StatusPill.js';
|
||||||
|
import { useLiveState } from './hooks/useLiveState.js';
|
||||||
|
import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
|
||||||
|
|
||||||
const API_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
|
const API_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
|
||||||
|
|
||||||
/**
|
/** Fallback in-memory snapshot so the scene has something to render
|
||||||
* Mock solar system so the scene has something to render before the
|
* even before the API is up. Matches the catalog used by the
|
||||||
* real KSP bridge is wired in (Phase 1). Drop a real snapshot in
|
* mock-telemetry publisher. */
|
||||||
* via the same /api/v1/state endpoint and the same renderer will
|
const FALLBACK_SNAPSHOT: UniverseSnapshot = {
|
||||||
* work — only `useUniverse()` changes.
|
ut: 0,
|
||||||
*/
|
capturedAt: new Date(0).toISOString(),
|
||||||
function useUniverse() {
|
activeVesselId: null,
|
||||||
const [bodies, setBodies] = useState<CelestialBody[]>([]);
|
bodies: [
|
||||||
const [ut, setUt] = useState(0);
|
{
|
||||||
const [error, setError] = useState<string | null>(null);
|
id: 'kerbol',
|
||||||
|
name: 'Kerbol',
|
||||||
useEffect(() => {
|
kind: 'star',
|
||||||
const mockBodies: CelestialBody[] = [
|
parentId: null,
|
||||||
makeBody({
|
radius: 261_600_000,
|
||||||
id: 'kerbol',
|
sphereOfInfluence: 1e30,
|
||||||
name: 'Kerbol',
|
gravitationalParameter: 1.172332794e18,
|
||||||
kind: 'star',
|
rotationPeriod: 432_000,
|
||||||
radius: 261_600_000,
|
axialTilt: 0,
|
||||||
sphereOfInfluence: Infinity,
|
orbit: {
|
||||||
mu: 1.172332794e18,
|
semiMajorAxis: 0,
|
||||||
rotationPeriod: 432_000,
|
eccentricity: 0,
|
||||||
axialTilt: 0,
|
inclination: 0,
|
||||||
color: '#ffcc33',
|
longitudeOfAscendingNode: 0,
|
||||||
parent: null,
|
argumentOfPeriapsis: 0,
|
||||||
sma: 0,
|
meanAnomalyAtEpoch: 0,
|
||||||
}),
|
epoch: 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,
|
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
}
|
id: 'kerbin',
|
||||||
|
name: 'Kerbin',
|
||||||
function Scene({ bodies, ut }: { bodies: CelestialBody[]; ut: number }) {
|
kind: 'planet',
|
||||||
const mountRef = useRef<HTMLDivElement>(null);
|
parentId: 'kerbol',
|
||||||
|
radius: 600_000,
|
||||||
useEffect(() => {
|
sphereOfInfluence: 84_159_286,
|
||||||
const mount = mountRef.current;
|
gravitationalParameter: 3.5316e12,
|
||||||
if (!mount) return;
|
rotationPeriod: 21_600,
|
||||||
|
axialTilt: 0,
|
||||||
const width = mount.clientWidth;
|
orbit: {
|
||||||
const height = mount.clientHeight;
|
semiMajorAxis: 13_599_840_256,
|
||||||
|
eccentricity: 0.05,
|
||||||
const scene = new THREE.Scene();
|
inclination: 0,
|
||||||
scene.background = new THREE.Color(0x000005);
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1e12);
|
meanAnomalyAtEpoch: 0.7,
|
||||||
camera.position.set(0, 5e9, 1.5e10);
|
epoch: 0,
|
||||||
camera.lookAt(0, 0, 0);
|
},
|
||||||
|
},
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
{
|
||||||
renderer.setSize(width, height);
|
id: 'mun',
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
name: 'Mun',
|
||||||
mount.appendChild(renderer.domElement);
|
kind: 'moon',
|
||||||
|
parentId: 'kerbin',
|
||||||
// Lights
|
radius: 200_000,
|
||||||
scene.add(new THREE.AmbientLight(0x404040, 0.4));
|
sphereOfInfluence: 2_429_559,
|
||||||
const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0);
|
gravitationalParameter: 6.514e10,
|
||||||
scene.add(sunLight);
|
rotationPeriod: 138_984,
|
||||||
|
axialTilt: 0,
|
||||||
// Build spheres + orbit lines per body
|
orbit: {
|
||||||
const meshById = new Map<string, THREE.Mesh>();
|
semiMajorAxis: 12_000_000,
|
||||||
const orbitGroup = new THREE.Group();
|
eccentricity: 0,
|
||||||
scene.add(orbitGroup);
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
for (const body of bodies) {
|
argumentOfPeriapsis: 0,
|
||||||
if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) {
|
meanAnomalyAtEpoch: 1.0,
|
||||||
// Star: render but skip orbit
|
epoch: 0,
|
||||||
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);
|
vessels: [],
|
||||||
scene.add(mesh);
|
groundStations: [],
|
||||||
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() {
|
export function App() {
|
||||||
const { bodies, ut, setUt, error } = useUniverse();
|
const { snapshot, status, lastUpdate, messageCount, error } = useLiveState(API_URL);
|
||||||
const [playing, setPlaying] = useState(true);
|
const liveUt = snapshot?.ut ?? 0;
|
||||||
const [speed, setSpeed] = useState(60); // 1s of wall = 60s of KSP UT
|
|
||||||
|
|
||||||
|
// Time controls
|
||||||
|
const [displayUt, setDisplayUt] = useState<number>(liveUt);
|
||||||
|
const [playing, setPlaying] = useState(true);
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Focus toggles
|
||||||
|
const [showPlanetOrbits, setShowPlanetOrbits] = useState(true);
|
||||||
|
const [showMoonOrbits, setShowMoonOrbits] = useState(true);
|
||||||
|
const [showVesselOrbits, setShowVesselOrbits] = useState(true);
|
||||||
|
|
||||||
|
// When a new snapshot arrives, jump displayUt to liveUt if we're
|
||||||
|
// currently "in sync" (playing and at the live edge).
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing && displayUt >= liveUt - 1) {
|
||||||
|
setDisplayUt(liveUt);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [liveUt]);
|
||||||
|
|
||||||
|
// Advance displayUt when playing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
const id = setInterval(() => setUt((u) => u + speed), 1000);
|
const id = setInterval(() => {
|
||||||
|
setDisplayUt((u) => u + speed);
|
||||||
|
}, 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [playing, speed]);
|
}, [playing, speed]);
|
||||||
|
|
||||||
|
const displaySnapshot: UniverseSnapshot = snapshot ?? FALLBACK_SNAPSHOT;
|
||||||
|
const vesselCount = displaySnapshot.vessels.length;
|
||||||
|
const bodyCount = displaySnapshot.bodies.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100vw', height: '100vh', position: 'relative', background: '#000' }}>
|
<div style={{ width: '100vw', height: '100vh', position: 'relative', background: '#000' }}>
|
||||||
<Scene bodies={bodies} ut={ut} />
|
<Scene
|
||||||
|
snapshot={displaySnapshot}
|
||||||
|
ut={displayUt}
|
||||||
|
followId={selectedId}
|
||||||
|
showPlanetOrbits={showPlanetOrbits}
|
||||||
|
showMoonOrbits={showMoonOrbits}
|
||||||
|
showVesselOrbits={showVesselOrbits}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<StatusPill
|
||||||
style={{
|
status={status}
|
||||||
position: 'absolute',
|
lastUpdate={lastUpdate}
|
||||||
top: 12,
|
messageCount={messageCount}
|
||||||
left: 12,
|
vesselCount={vesselCount}
|
||||||
padding: '0.5rem 0.75rem',
|
bodyCount={bodyCount}
|
||||||
background: 'rgba(0,0,0,0.6)',
|
/>
|
||||||
color: 'white',
|
|
||||||
borderRadius: 6,
|
<TimeControls
|
||||||
fontFamily: 'monospace',
|
ut={displayUt}
|
||||||
fontSize: 13,
|
liveUt={liveUt}
|
||||||
|
playing={playing}
|
||||||
|
speed={speed}
|
||||||
|
onPlayPause={() => setPlaying((p) => !p)}
|
||||||
|
onSpeed={(s) => {
|
||||||
|
setSpeed(s);
|
||||||
|
if (s > 0) setPlaying(true);
|
||||||
}}
|
}}
|
||||||
>
|
onReverse={() => setSpeed((s) => -s)}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
onReset={() => {
|
||||||
<strong>Kerbal RT</strong>
|
setDisplayUt(liveUt);
|
||||||
<Pill tone={error ? 'danger' : 'success'}>
|
setPlaying(true);
|
||||||
{error ? 'API offline' : `${bodies.length} bodies`}
|
setSpeed(1);
|
||||||
</Pill>
|
setSelectedId(null);
|
||||||
|
}}
|
||||||
|
onScrub={(u) => {
|
||||||
|
setDisplayUt(u);
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VesselList
|
||||||
|
vessels={displaySnapshot.vessels}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FocusPanel
|
||||||
|
showPlanetOrbits={showPlanetOrbits}
|
||||||
|
showMoonOrbits={showMoonOrbits}
|
||||||
|
showVesselOrbits={showVesselOrbits}
|
||||||
|
onTogglePlanet={() => setShowPlanetOrbits((v) => !v)}
|
||||||
|
onToggleMoon={() => setShowMoonOrbits((v) => !v)}
|
||||||
|
onToggleVessel={() => setShowVesselOrbits((v) => !v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
left: 12,
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
background: 'rgba(200, 0, 0, 0.3)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
color: 'rgba(255,255,255,0.5)',
|
color: 'rgba(255,255,255,0.4)',
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Phase 0 skeleton · Three.js
|
Phase 2 — live map · mock-telemetry driving the WS
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* useLiveState — subscribe to the kerbal-rt API over WebSocket and
|
||||||
|
* expose the latest UniverseSnapshot plus connection status.
|
||||||
|
*
|
||||||
|
* Auto-reconnects with exponential backoff on drop. Falls back to
|
||||||
|
* polling /api/v1/state every 5s if WebSocket fails to connect after
|
||||||
|
* 3 retries (e.g. a proxy doesn't support upgrade).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types';
|
||||||
|
|
||||||
|
export type ConnectionState = 'connecting' | 'open' | 'closed' | 'fallback';
|
||||||
|
|
||||||
|
export interface LiveState {
|
||||||
|
snapshot: UniverseSnapshot | null;
|
||||||
|
status: ConnectionState;
|
||||||
|
lastUpdate: string | null;
|
||||||
|
messageCount: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10_000, 30_000];
|
||||||
|
|
||||||
|
export function useLiveState(apiUrl: string): LiveState {
|
||||||
|
const [snapshot, setSnapshot] = useState<UniverseSnapshot | null>(null);
|
||||||
|
const [status, setStatus] = useState<ConnectionState>('connecting');
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||||
|
const [messageCount, setMessageCount] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const reconnectAttempt = useRef(0);
|
||||||
|
const stopped = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stopped.current = false;
|
||||||
|
let pollTimer: number | null = null;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
const startPollingFallback = () => {
|
||||||
|
if (pollTimer) return;
|
||||||
|
setStatus('fallback');
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/api/v1/state`);
|
||||||
|
if (res.ok) {
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body.error && body.data) {
|
||||||
|
setSnapshot(body.data as UniverseSnapshot);
|
||||||
|
setLastUpdate(new Date().toISOString());
|
||||||
|
setMessageCount((n) => n + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String((e as Error).message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
pollTimer = window.setInterval(tick, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (stopped.current) return;
|
||||||
|
if (reconnectAttempt.current >= RECONNECT_DELAYS_MS.length) {
|
||||||
|
// Gave up on WS, fall back to polling.
|
||||||
|
startPollingFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = RECONNECT_DELAYS_MS[reconnectAttempt.current] ?? 30_000;
|
||||||
|
reconnectAttempt.current += 1;
|
||||||
|
setStatus('closed');
|
||||||
|
reconnectTimer = window.setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (stopped.current) return;
|
||||||
|
setStatus('connecting');
|
||||||
|
setError(null);
|
||||||
|
const wsUrl = apiUrl.replace(/^http/, 'ws') + '/api/v1/live';
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String((e as Error).message ?? e));
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('open');
|
||||||
|
reconnectAttempt.current = 0; // success — reset backoff
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
setMessageCount((n) => n + 1);
|
||||||
|
setLastUpdate(new Date().toISOString());
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data) as LiveMessage;
|
||||||
|
if (msg.type === 'snapshot') {
|
||||||
|
setSnapshot(msg.snapshot);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`bad message: ${String((e as Error).message ?? e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
// close will fire too
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
stopped.current = true;
|
||||||
|
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||||
|
if (pollTimer) window.clearInterval(pollTimer);
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
return { snapshot, status, lastUpdate, messageCount, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* FocusPanel — toggles for which orbit-line categories are visible.
|
||||||
|
*/
|
||||||
|
export interface FocusPanelProps {
|
||||||
|
showPlanetOrbits: boolean;
|
||||||
|
showMoonOrbits: boolean;
|
||||||
|
showVesselOrbits: boolean;
|
||||||
|
onTogglePlanet: () => void;
|
||||||
|
onToggleMoon: () => void;
|
||||||
|
onToggleVessel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_STYLE: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_STYLE: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FocusPanel(props: FocusPanelProps) {
|
||||||
|
const {
|
||||||
|
showPlanetOrbits,
|
||||||
|
showMoonOrbits,
|
||||||
|
showVesselOrbits,
|
||||||
|
onTogglePlanet,
|
||||||
|
onToggleMoon,
|
||||||
|
onToggleVessel,
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<div style={PANEL_STYLE}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
opacity: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orbits
|
||||||
|
</div>
|
||||||
|
<label style={ROW_STYLE}>
|
||||||
|
<input type="checkbox" checked={showPlanetOrbits} onChange={onTogglePlanet} />
|
||||||
|
Planets
|
||||||
|
</label>
|
||||||
|
<label style={ROW_STYLE}>
|
||||||
|
<input type="checkbox" checked={showMoonOrbits} onChange={onToggleMoon} />
|
||||||
|
Moons
|
||||||
|
</label>
|
||||||
|
<label style={ROW_STYLE}>
|
||||||
|
<input type="checkbox" checked={showVesselOrbits} onChange={onToggleVessel} />
|
||||||
|
Vessels
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* StatusPill — connection + stale-data indicator.
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - WS connection state (open / connecting / closed / fallback)
|
||||||
|
* - "Data may be stale" warning if lastUpdate > STALE_THRESHOLD_S
|
||||||
|
* - Number of messages received
|
||||||
|
*/
|
||||||
|
import { Pill } from '@kerbal-rt/ui';
|
||||||
|
import type { ConnectionState } from '../hooks/useLiveState.js';
|
||||||
|
|
||||||
|
export interface StatusPillProps {
|
||||||
|
status: ConnectionState;
|
||||||
|
lastUpdate: string | null;
|
||||||
|
messageCount: number;
|
||||||
|
vesselCount: number;
|
||||||
|
bodyCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE_THRESHOLD_S = 60;
|
||||||
|
|
||||||
|
export function StatusPill({
|
||||||
|
status,
|
||||||
|
lastUpdate,
|
||||||
|
messageCount,
|
||||||
|
vesselCount,
|
||||||
|
bodyCount,
|
||||||
|
}: StatusPillProps) {
|
||||||
|
const isStale = lastUpdate
|
||||||
|
? (Date.now() - new Date(lastUpdate).getTime()) / 1000 > STALE_THRESHOLD_S
|
||||||
|
: true;
|
||||||
|
const tone =
|
||||||
|
status === 'open' && !isStale
|
||||||
|
? 'success'
|
||||||
|
: status === 'connecting' || status === 'fallback'
|
||||||
|
? 'warn'
|
||||||
|
: 'danger';
|
||||||
|
const label =
|
||||||
|
status === 'open'
|
||||||
|
? isStale
|
||||||
|
? 'STALE'
|
||||||
|
: 'LIVE'
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'CONNECTING'
|
||||||
|
: status === 'fallback'
|
||||||
|
? 'POLLING'
|
||||||
|
: 'OFFLINE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
borderRadius: 6,
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pill tone={tone}>{label}</Pill>
|
||||||
|
<Pill tone="neutral">{bodyCount} bodies</Pill>
|
||||||
|
<Pill tone="neutral">{vesselCount} vessels</Pill>
|
||||||
|
<Pill tone="neutral">{messageCount} msgs</Pill>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* TimeControls — play/pause, speed multiplier, reverse, reset,
|
||||||
|
* and a slider to scrub the simulation UT.
|
||||||
|
*
|
||||||
|
* Time source of truth: `ut`. The Scene re-propagates every time
|
||||||
|
* `ut` changes. Mock-telemetry advances UT at 1 KSP-second per
|
||||||
|
* wall-clock second; pressing ▶ at speed ×1 gives you live
|
||||||
|
* playback. Higher speeds fast-forward.
|
||||||
|
*/
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
export interface TimeControlsProps {
|
||||||
|
ut: number;
|
||||||
|
/** UT of the most recent server snapshot (the "live edge"). */
|
||||||
|
liveUt: number;
|
||||||
|
playing: boolean;
|
||||||
|
speed: number;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onSpeed: (s: number) => void;
|
||||||
|
onReverse: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onScrub: (ut: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEEDS: { value: number; label: string }[] = [
|
||||||
|
{ value: 1, label: '×1' },
|
||||||
|
{ value: 10, label: '×10' },
|
||||||
|
{ value: 100, label: '×100' },
|
||||||
|
{ value: 1000, label: '×1k' },
|
||||||
|
{ value: 10000, label: '×10k' },
|
||||||
|
{ value: 100000, label: '×100k' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PANEL_STYLE: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
minWidth: 380,
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUTTON_STYLE: React.CSSProperties = {
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: '#e6e6ee',
|
||||||
|
padding: '0.2rem 0.4rem',
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_BUTTON: React.CSSProperties = {
|
||||||
|
...BUTTON_STYLE,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeControls(props: TimeControlsProps) {
|
||||||
|
const { ut, liveUt, playing, speed, onPlayPause, onSpeed, onReverse, onReset, onScrub } = props;
|
||||||
|
const isLive = ut === liveUt;
|
||||||
|
const behind = liveUt - ut;
|
||||||
|
const behindLabel =
|
||||||
|
behind === 0
|
||||||
|
? 'live'
|
||||||
|
: behind > 0
|
||||||
|
? `${formatKspDuration(behind)} behind`
|
||||||
|
: `${formatKspDuration(-behind)} ahead`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={PANEL_STYLE}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<button onClick={onPlayPause} style={BUTTON_STYLE} title={playing ? 'Pause' : 'Play'}>
|
||||||
|
{playing ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onReverse} style={BUTTON_STYLE} title="Reverse time">
|
||||||
|
⏪
|
||||||
|
</button>
|
||||||
|
<button onClick={onReset} style={BUTTON_STYLE} title="Snap to live">
|
||||||
|
⤴
|
||||||
|
</button>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{SPEEDS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => onSpeed(s.value)}
|
||||||
|
style={speed === s.value ? ACTIVE_BUTTON : BUTTON_STYLE}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
|
||||||
|
<span style={{ opacity: 0.6 }}>UT</span>
|
||||||
|
<span style={{ minWidth: 120 }}>{formatKspTime(ut)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={Math.max(0, liveUt - 5 * 365 * 24 * 3600)}
|
||||||
|
max={liveUt + 365 * 24 * 3600}
|
||||||
|
step={60}
|
||||||
|
value={ut}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => onScrub(Number(e.target.value))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
opacity: 0.7,
|
||||||
|
color: isLive ? '#7dff7d' : '#ffcc44',
|
||||||
|
minWidth: 90,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{behindLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KSP_DAY_SECONDS = 6 * 3600;
|
||||||
|
const KSP_YEAR_DAYS = 426;
|
||||||
|
|
||||||
|
function formatKspTime(ut: number): string {
|
||||||
|
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 h = Math.floor(secondsInDay / 3600);
|
||||||
|
const m = Math.floor((secondsInDay % 3600) / 60);
|
||||||
|
return `Y${year} D${String(day).padStart(3, '0')} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKspDuration(seconds: number): string {
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||||
|
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||||
|
if (seconds < KSP_YEAR_DAYS * KSP_DAY_SECONDS) return `${(seconds / 86400).toFixed(1)}d`;
|
||||||
|
return `${(seconds / (KSP_YEAR_DAYS * KSP_DAY_SECONDS)).toFixed(1)}y`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* VesselList — sidebar of currently-known vessels.
|
||||||
|
*
|
||||||
|
* Click a vessel to track it (the camera follows + the orbit is
|
||||||
|
* highlighted). Click again to untrack.
|
||||||
|
*/
|
||||||
|
import type { Vessel } from '@kerbal-rt/shared-types';
|
||||||
|
import { vesselColor } from '../scene/color.js';
|
||||||
|
|
||||||
|
export interface VesselListProps {
|
||||||
|
vessels: Vessel[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_STYLE: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 110,
|
||||||
|
left: 12,
|
||||||
|
width: 280,
|
||||||
|
maxHeight: 'calc(100vh - 200px)',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '0.5rem',
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_STYLE: React.CSSProperties = {
|
||||||
|
padding: '0.4rem 0.5rem',
|
||||||
|
marginBottom: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_ACTIVE: React.CSSProperties = {
|
||||||
|
...ROW_STYLE,
|
||||||
|
background: 'rgba(255,255,255,0.12)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOT_STYLE: (color: number) => React.CSSProperties = (color) => ({
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `#${color.toString(16).padStart(6, '0')}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function VesselList({ vessels, selectedId, onSelect }: VesselListProps) {
|
||||||
|
const sorted = [...vessels].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return (
|
||||||
|
<div style={PANEL_STYLE}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
opacity: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Vessels ({vessels.length})
|
||||||
|
</div>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div style={{ opacity: 0.4, padding: 4 }}>No vessels yet.</div>
|
||||||
|
) : (
|
||||||
|
sorted.map((v) => {
|
||||||
|
const color = vesselColor(v.owner);
|
||||||
|
const isActive = selectedId === v.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
style={isActive ? ROW_ACTIVE : ROW_STYLE}
|
||||||
|
onClick={() => onSelect(isActive ? null : v.id)}
|
||||||
|
title={v.id}
|
||||||
|
>
|
||||||
|
<span style={DOT_STYLE(color)} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.6, fontSize: 10 }}>
|
||||||
|
{v.owner ?? '—'} · {v.situation.toLowerCase()} · {v.referenceBodyId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* Scene — the 3D Three.js rendering of the universe.
|
||||||
|
*
|
||||||
|
* Re-renders when the bodies, vessels, or focus settings change.
|
||||||
|
* The animation loop runs the orbit propagation and positions the
|
||||||
|
* meshes for the current `ut`.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { sampleOrbit } from '@kerbal-rt/orbital-math';
|
||||||
|
import type { UniverseSnapshot } from '@kerbal-rt/shared-types';
|
||||||
|
import { bodyColor, vesselColor } from './color.js';
|
||||||
|
import { bodyPositionAt, vesselPositionAt } from './layout.js';
|
||||||
|
|
||||||
|
export interface SceneProps {
|
||||||
|
snapshot: UniverseSnapshot;
|
||||||
|
ut: number;
|
||||||
|
/** Which body or vessel the camera should follow. null = free. */
|
||||||
|
followId: string | null;
|
||||||
|
/** Toggle visibility of orbit lines by category. */
|
||||||
|
showPlanetOrbits: boolean;
|
||||||
|
showMoonOrbits: boolean;
|
||||||
|
showVesselOrbits: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SceneRefs {
|
||||||
|
scene: THREE.Scene;
|
||||||
|
camera: THREE.PerspectiveCamera;
|
||||||
|
renderer: THREE.WebGLRenderer;
|
||||||
|
bodyMeshes: Map<string, THREE.Mesh>;
|
||||||
|
vesselMeshes: Map<string, THREE.Mesh>;
|
||||||
|
orbitLines: Map<string, THREE.Line>;
|
||||||
|
mount: HTMLDivElement;
|
||||||
|
raf: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORBIT_OPACITY: Record<string, number> = {
|
||||||
|
planet: 0.5,
|
||||||
|
moon: 0.4,
|
||||||
|
vessel: 0.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Scene(props: SceneProps) {
|
||||||
|
const { snapshot, ut, followId, showPlanetOrbits, showMoonOrbits, showVesselOrbits } = props;
|
||||||
|
const mountRef = useRef<HTMLDivElement>(null);
|
||||||
|
const refsRef = useRef<SceneRefs | null>(null);
|
||||||
|
|
||||||
|
// One-time scene setup
|
||||||
|
useEffect(() => {
|
||||||
|
const mount = mountRef.current;
|
||||||
|
if (!mount) return;
|
||||||
|
const refs = createScene(mount);
|
||||||
|
refsRef.current = refs;
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
const r = refsRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
const w = mount.clientWidth;
|
||||||
|
const h = mount.clientHeight;
|
||||||
|
r.camera.aspect = w / h;
|
||||||
|
r.camera.updateProjectionMatrix();
|
||||||
|
r.renderer.setSize(w, h);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
cancelAnimationFrame(refs.raf);
|
||||||
|
refs.renderer.dispose();
|
||||||
|
if (mount.contains(refs.renderer.domElement)) {
|
||||||
|
mount.removeChild(refs.renderer.domElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Rebuild the body / vessel meshes whenever the snapshot's set of
|
||||||
|
// bodies or vessels changes (not on every snapshot — they're stable).
|
||||||
|
useEffect(() => {
|
||||||
|
const refs = refsRef.current;
|
||||||
|
if (!refs) return;
|
||||||
|
rebuildMeshes(refs, snapshot);
|
||||||
|
}, [snapshot.bodies, snapshot.vessels, snapshot]);
|
||||||
|
|
||||||
|
// Toggle orbit line visibility
|
||||||
|
useEffect(() => {
|
||||||
|
const refs = refsRef.current;
|
||||||
|
if (!refs) return;
|
||||||
|
for (const [id, line] of refs.orbitLines.entries()) {
|
||||||
|
const isPlanet = snapshot.bodies.find((b) => b.id === id && b.kind === 'planet');
|
||||||
|
const isMoon = snapshot.bodies.find((b) => b.id === id && b.kind === 'moon');
|
||||||
|
if (isPlanet) line.visible = showPlanetOrbits;
|
||||||
|
else if (isMoon) line.visible = showMoonOrbits;
|
||||||
|
else line.visible = showVesselOrbits; // vessel
|
||||||
|
}
|
||||||
|
}, [showPlanetOrbits, showMoonOrbits, showVesselOrbits, snapshot.bodies]);
|
||||||
|
|
||||||
|
// Per-frame: propagate, position meshes, follow camera
|
||||||
|
useEffect(() => {
|
||||||
|
const refs = refsRef.current;
|
||||||
|
if (!refs) return;
|
||||||
|
|
||||||
|
let lastUt = Number.NEGATIVE_INFINITY;
|
||||||
|
const render = () => {
|
||||||
|
// Re-propagate positions whenever ut changes
|
||||||
|
if (ut !== lastUt) {
|
||||||
|
lastUt = ut;
|
||||||
|
positionMeshes(refs, snapshot, ut);
|
||||||
|
}
|
||||||
|
// Camera follow
|
||||||
|
if (followId) {
|
||||||
|
const followPos = getFollowPosition(snapshot, followId, ut);
|
||||||
|
if (followPos) {
|
||||||
|
refs.camera.position.lerp(
|
||||||
|
new THREE.Vector3(followPos.x, followPos.y, followPos.z).multiplyScalar(1.05),
|
||||||
|
0.05,
|
||||||
|
);
|
||||||
|
// Also add a small offset for context
|
||||||
|
const target = new THREE.Vector3(followPos.x, followPos.y, followPos.z);
|
||||||
|
refs.camera.lookAt(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refs.renderer.render(refs.scene, refs.camera);
|
||||||
|
refs.raf = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
refs.raf = requestAnimationFrame(render);
|
||||||
|
return () => cancelAnimationFrame(refs.raf);
|
||||||
|
}, [snapshot, ut, followId]);
|
||||||
|
|
||||||
|
return <div ref={mountRef} style={{ width: '100%', height: '100%' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Three.js setup helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createScene(mount: HTMLDivElement): SceneRefs {
|
||||||
|
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, 1e6, 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);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0x404040, 0.4));
|
||||||
|
const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0);
|
||||||
|
scene.add(sunLight);
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
const w = mount.clientWidth;
|
||||||
|
const h = mount.clientHeight;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
renderer,
|
||||||
|
bodyMeshes: new Map(),
|
||||||
|
vesselMeshes: new Map(),
|
||||||
|
orbitLines: new Map(),
|
||||||
|
mount,
|
||||||
|
raf: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMeshes(refs: SceneRefs, snap: UniverseSnapshot): void {
|
||||||
|
// Remove existing meshes / lines
|
||||||
|
for (const mesh of refs.bodyMeshes.values()) {
|
||||||
|
refs.scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
(mesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
for (const mesh of refs.vesselMeshes.values()) {
|
||||||
|
refs.scene.remove(mesh);
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
(mesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
for (const line of refs.orbitLines.values()) {
|
||||||
|
refs.scene.remove(line);
|
||||||
|
line.geometry.dispose();
|
||||||
|
(line.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
refs.bodyMeshes.clear();
|
||||||
|
refs.vesselMeshes.clear();
|
||||||
|
refs.orbitLines.clear();
|
||||||
|
|
||||||
|
for (const body of snap.bodies) {
|
||||||
|
if (body.kind === 'star') {
|
||||||
|
const geo = new THREE.SphereGeometry(Math.max(body.radius, 1e8), 32, 16);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color: bodyColor(body.id) });
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
refs.scene.add(mesh);
|
||||||
|
refs.bodyMeshes.set(body.id, mesh);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (body.parentId === null) continue;
|
||||||
|
|
||||||
|
// Body sphere
|
||||||
|
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);
|
||||||
|
refs.scene.add(mesh);
|
||||||
|
refs.bodyMeshes.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: ORBIT_OPACITY[body.kind] ?? 0.5,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const line = new THREE.LineLoop(lineGeo, lineMat);
|
||||||
|
refs.scene.add(line);
|
||||||
|
refs.orbitLines.set(body.id, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vessel of snap.vessels) {
|
||||||
|
const geo = new THREE.SphereGeometry(2e5, 12, 8);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color: vesselColor(vessel.owner) });
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
refs.scene.add(mesh);
|
||||||
|
refs.vesselMeshes.set(vessel.id, mesh);
|
||||||
|
|
||||||
|
// Vessel orbit (relative to its reference body)
|
||||||
|
if (vessel.referenceBodyId) {
|
||||||
|
const ref = snap.bodies.find((b) => b.id === vessel.referenceBodyId);
|
||||||
|
if (ref) {
|
||||||
|
const points = sampleOrbit(vessel.orbit, ref.gravitationalParameter, 128);
|
||||||
|
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: vesselColor(vessel.owner),
|
||||||
|
opacity: 0.5,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const line = new THREE.LineLoop(lineGeo, lineMat);
|
||||||
|
refs.scene.add(line);
|
||||||
|
refs.orbitLines.set(vessel.id, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionMeshes(refs: SceneRefs, snap: UniverseSnapshot, ut: number): void {
|
||||||
|
for (const body of snap.bodies) {
|
||||||
|
if (body.kind === 'star' || body.parentId === null) continue;
|
||||||
|
const pos = bodyPositionAt(snap.bodies, body.id, ut);
|
||||||
|
const mesh = refs.bodyMeshes.get(body.id);
|
||||||
|
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
|
||||||
|
}
|
||||||
|
for (const vessel of snap.vessels) {
|
||||||
|
const pos = vesselPositionAt(snap.bodies, vessel, ut);
|
||||||
|
const mesh = refs.vesselMeshes.get(vessel.id);
|
||||||
|
if (mesh) mesh.position.set(pos.x, pos.y, pos.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFollowPosition(
|
||||||
|
snap: UniverseSnapshot,
|
||||||
|
id: string,
|
||||||
|
ut: number,
|
||||||
|
): { x: number; y: number; z: number } | null {
|
||||||
|
const vessel = snap.vessels.find((v) => v.id === id);
|
||||||
|
if (vessel) return vesselPositionAt(snap.bodies, vessel, ut);
|
||||||
|
const body = snap.bodies.find((b) => b.id === id);
|
||||||
|
if (body) return bodyPositionAt(snap.bodies, id, ut);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Color-coding for celestial bodies. Identical to the catalog in
|
||||||
|
* apps/tools/mock-telemetry/src/catalog.ts so the mock and the
|
||||||
|
* renderer agree.
|
||||||
|
*
|
||||||
|
* Returns a Three.js-friendly 0xRRGGBB number.
|
||||||
|
*/
|
||||||
|
export function bodyColor(id: string): number {
|
||||||
|
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,
|
||||||
|
dres: 0x665544,
|
||||||
|
eeloo: 0xeeeeff,
|
||||||
|
};
|
||||||
|
return map[id] ?? 0xffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color-coding for vessels by owner agency.
|
||||||
|
*/
|
||||||
|
export function vesselColor(owner: string | null): number {
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
KASA: 0x44aaff, // blue
|
||||||
|
SPES: 0xff6644, // orange
|
||||||
|
};
|
||||||
|
return map[owner ?? ''] ?? 0xcccccc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Layout helpers for the 3D scene.
|
||||||
|
*
|
||||||
|
* - bodies are positioned in heliocentric inertial frame, in meters
|
||||||
|
* - the KSP system is huge (Eeloo is ~9e10 m from Kerbol) so we
|
||||||
|
* render with a logarithmic distance scale for the camera radius
|
||||||
|
* to keep the inner planets visible alongside the outer ones
|
||||||
|
*/
|
||||||
|
import type { CelestialBody, Vessel } from '@kerbal-rt/shared-types';
|
||||||
|
import { positionAt } from '@kerbal-rt/orbital-math';
|
||||||
|
|
||||||
|
/** Find a body's gravitational parameter (μ) given its id.
|
||||||
|
* Misleadingly named "findParentMu" historically; the function
|
||||||
|
* returns the body's own μ, used for propagating vessels around it. */
|
||||||
|
export function findBodyMu(bodies: CelestialBody[], id: string | null): number {
|
||||||
|
if (!id) return 0;
|
||||||
|
const body = bodies.find((b) => b.id === id);
|
||||||
|
return body?.gravitationalParameter ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position of a body in the heliocentric inertial frame, propagated
|
||||||
|
* to the given UT by walking up the parent chain.
|
||||||
|
*/
|
||||||
|
export function bodyPositionAt(
|
||||||
|
bodies: CelestialBody[],
|
||||||
|
bodyId: string,
|
||||||
|
ut: number,
|
||||||
|
): { x: number; y: number; z: number } {
|
||||||
|
const body = bodies.find((b) => b.id === bodyId);
|
||||||
|
if (!body) return { x: 0, y: 0, z: 0 };
|
||||||
|
if (!body.parentId) return { x: 0, y: 0, z: 0 }; // root (the star)
|
||||||
|
const parent = bodies.find((b) => b.id === body.parentId);
|
||||||
|
if (!parent) return { x: 0, y: 0, z: 0 };
|
||||||
|
return positionAt(body.orbit, parent.gravitationalParameter, ut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position of a vessel, propagated to UT, in the heliocentric frame. */
|
||||||
|
export function vesselPositionAt(
|
||||||
|
bodies: CelestialBody[],
|
||||||
|
vessel: Vessel,
|
||||||
|
ut: number,
|
||||||
|
): { x: number; y: number; z: number } {
|
||||||
|
const refMu = findBodyMu(bodies, vessel.referenceBodyId);
|
||||||
|
const refPos = bodyPositionAt(bodies, vessel.referenceBodyId, ut);
|
||||||
|
const local = positionAt(vessel.orbit, refMu, ut);
|
||||||
|
return {
|
||||||
|
x: refPos.x + local.x,
|
||||||
|
y: refPos.y + local.y,
|
||||||
|
z: refPos.z + local.z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log-scaled camera distance. Maps a desired real distance to a
|
||||||
|
* Three.js camera position that keeps both inner and outer planets
|
||||||
|
* visible. d_real = exp(t) * 1e8 m → e.g. for t=4, distance=5.5e9 m.
|
||||||
|
*/
|
||||||
|
export function logScale(t: number): number {
|
||||||
|
return Math.exp(t) * 1e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inverseLogScale(d: number): number {
|
||||||
|
return Math.log(d / 1e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reasonable initial camera radius that shows the inner planets. */
|
||||||
|
export const DEFAULT_CAMERA_RADIUS = 1.5e10;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
bodyPositionAt,
|
||||||
|
vesselPositionAt,
|
||||||
|
findBodyMu,
|
||||||
|
logScale,
|
||||||
|
inverseLogScale,
|
||||||
|
} from '../src/scene/layout.js';
|
||||||
|
import type { CelestialBody, Vessel } from '@kerbal-rt/shared-types';
|
||||||
|
|
||||||
|
const kerbol: CelestialBody = {
|
||||||
|
id: 'kerbol',
|
||||||
|
name: 'Kerbol',
|
||||||
|
kind: 'star',
|
||||||
|
parentId: null,
|
||||||
|
radius: 261_600_000,
|
||||||
|
sphereOfInfluence: 1e30,
|
||||||
|
gravitationalParameter: 1.172e18,
|
||||||
|
rotationPeriod: 432_000,
|
||||||
|
axialTilt: 0,
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 0,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const kerbin: CelestialBody = {
|
||||||
|
id: 'kerbin',
|
||||||
|
name: 'Kerbin',
|
||||||
|
kind: 'planet',
|
||||||
|
parentId: 'kerbol',
|
||||||
|
radius: 600_000,
|
||||||
|
sphereOfInfluence: 84_159_286,
|
||||||
|
gravitationalParameter: 3.5316e12,
|
||||||
|
rotationPeriod: 21_600,
|
||||||
|
axialTilt: 0,
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 13_599_840_256,
|
||||||
|
eccentricity: 0, // circular for predictable test math
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mun: CelestialBody = {
|
||||||
|
id: 'mun',
|
||||||
|
name: 'Mun',
|
||||||
|
kind: 'moon',
|
||||||
|
parentId: 'kerbin',
|
||||||
|
radius: 200_000,
|
||||||
|
sphereOfInfluence: 2_429_559,
|
||||||
|
gravitationalParameter: 6.514e10,
|
||||||
|
rotationPeriod: 138_984,
|
||||||
|
axialTilt: 0,
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 12_000_000,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodies: CelestialBody[] = [kerbol, kerbin, mun];
|
||||||
|
|
||||||
|
describe('bodyPositionAt', () => {
|
||||||
|
it('returns origin for the star', () => {
|
||||||
|
const p = bodyPositionAt(bodies, 'kerbol', 100);
|
||||||
|
expect(p.x).toBe(0);
|
||||||
|
expect(p.y).toBe(0);
|
||||||
|
expect(p.z).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a non-zero position for a planet at t > 0', () => {
|
||||||
|
const p = bodyPositionAt(bodies, 'kerbin', 1_000_000);
|
||||||
|
const dist = Math.hypot(p.x, p.y, p.z);
|
||||||
|
// SMA is 13.6 billion meters, so position is on that order
|
||||||
|
expect(dist).toBeGreaterThan(1e10);
|
||||||
|
expect(dist).toBeLessThan(2e10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns origin for a body not in the catalog', () => {
|
||||||
|
const p = bodyPositionAt(bodies, 'unknown', 1);
|
||||||
|
expect(p).toEqual({ x: 0, y: 0, z: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is periodic (returns near the same position after one full orbit)', () => {
|
||||||
|
// Kerbin orbits Kerbol, so the period is computed with kerbol's μ
|
||||||
|
const p1 = bodyPositionAt(bodies, 'kerbin', 0);
|
||||||
|
const period =
|
||||||
|
(2 * Math.PI) / Math.sqrt(kerbol.gravitationalParameter / Math.pow(13_599_840_256, 3));
|
||||||
|
const p2 = bodyPositionAt(bodies, 'kerbin', period);
|
||||||
|
expect(p1.x).toBeCloseTo(p2.x, -2);
|
||||||
|
expect(p1.y).toBeCloseTo(p2.y, -2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vesselPositionAt', () => {
|
||||||
|
const vessel: Vessel = {
|
||||||
|
id: 'v1',
|
||||||
|
name: 'Test',
|
||||||
|
type: 'Probe',
|
||||||
|
owner: 'KASA',
|
||||||
|
situation: 'ORBITING',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
orbit: {
|
||||||
|
semiMajorAxis: 7_000_000,
|
||||||
|
eccentricity: 0,
|
||||||
|
inclination: 0,
|
||||||
|
longitudeOfAscendingNode: 0,
|
||||||
|
argumentOfPeriapsis: 0,
|
||||||
|
meanAnomalyAtEpoch: 0,
|
||||||
|
epoch: 0,
|
||||||
|
},
|
||||||
|
referenceBodyId: 'kerbin',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
retiredAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns the kerbin-centered position when reference body is kerbin', () => {
|
||||||
|
// At t=0, vessel is at (7e6, 0, 0) in kerbin frame
|
||||||
|
const p = vesselPositionAt(bodies, vessel, 0);
|
||||||
|
// kerbin is at (sma, 0, 0) = (13.6e9, 0, 0)
|
||||||
|
// so vessel should be at roughly (13.6e9 + 7e6, 0, 0)
|
||||||
|
expect(p.x).toBeCloseTo(13_599_840_256 + 7_000_000, -2);
|
||||||
|
expect(p.y).toBeCloseTo(0, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findBodyMu', () => {
|
||||||
|
it("returns the body's own gravitational parameter", () => {
|
||||||
|
expect(findBodyMu(bodies, 'kerbin')).toBe(kerbin.gravitationalParameter);
|
||||||
|
});
|
||||||
|
it('returns 0 for null id', () => {
|
||||||
|
expect(findBodyMu(bodies, null)).toBe(0);
|
||||||
|
});
|
||||||
|
it('returns 0 for unknown id', () => {
|
||||||
|
expect(findBodyMu(bodies, 'nope')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logScale', () => {
|
||||||
|
it('is the inverse of inverseLogScale', () => {
|
||||||
|
for (const t of [0, 4, 8, 12]) {
|
||||||
|
const d = logScale(t);
|
||||||
|
expect(inverseLogScale(d)).toBeCloseTo(t, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('produces reasonable distances for camera placement', () => {
|
||||||
|
// t=4 → ~5.5e9 m (good for showing Kerbin at 13.6e9)
|
||||||
|
expect(logScale(4)).toBeGreaterThan(1e9);
|
||||||
|
expect(logScale(4)).toBeLessThan(1e10);
|
||||||
|
// t=10 → ~22e12 m (good for showing all planets)
|
||||||
|
expect(logScale(10)).toBeGreaterThan(1e12);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3
@@ -139,6 +139,9 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.6
|
specifier: ^5.4.6
|
||||||
version: 5.4.21(@types/node@22.19.19)
|
version: 5.4.21(@types/node@22.19.19)
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.9(@types/node@22.19.19)
|
||||||
|
|
||||||
apps/tools/mock-telemetry:
|
apps/tools/mock-telemetry:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user