diff --git a/apps/live-map/package.json b/apps/live-map/package.json index 6898df6..fa7b13e 100644 --- a/apps/live-map/package.json +++ b/apps/live-map/package.json @@ -10,7 +10,7 @@ "preview": "vite preview --port 3001", "typecheck": "tsc --noEmit", "lint": "echo 'no linter yet'", - "test": "echo 'no tests yet'" + "test": "vitest run" }, "dependencies": { "@kerbal-rt/shared-types": "workspace:*", @@ -26,6 +26,7 @@ "@types/three": "^0.169.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.6.2", - "vite": "^5.4.6" + "vite": "^5.4.6", + "vitest": "^2.1.1" } } diff --git a/apps/live-map/src/App.tsx b/apps/live-map/src/App.tsx index a850535..3d0d027 100644 --- a/apps/live-map/src/App.tsx +++ b/apps/live-map/src/App.tsx @@ -1,313 +1,226 @@ -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'; +/** + * App — top-level live map. + * + * Subscribes to the API WebSocket, manages the simulation time + * (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) ?? ''; -/** - * Mock solar system so the scene has something to render before the - * real KSP bridge is wired in (Phase 1). Drop a real snapshot in - * via the same /api/v1/state endpoint and the same renderer will - * work — only `useUniverse()` changes. - */ -function useUniverse() { - const [bodies, setBodies] = useState([]); - const [ut, setUt] = useState(0); - const [error, setError] = useState(null); - - useEffect(() => { - const mockBodies: CelestialBody[] = [ - makeBody({ - id: 'kerbol', - name: 'Kerbol', - kind: 'star', - radius: 261_600_000, - sphereOfInfluence: Infinity, - mu: 1.172332794e18, - rotationPeriod: 432_000, - axialTilt: 0, - color: '#ffcc33', - parent: null, - sma: 0, - }), - makeBody({ - id: 'kerbin', - name: 'Kerbin', - kind: 'planet', - radius: 600_000, - sphereOfInfluence: 84_159_286, - mu: 3.5316e12, - rotationPeriod: 21_600, - axialTilt: 0, - color: '#3a7d8c', - parent: 'kerbol', - sma: 13_599_840_256, - }), - makeBody({ - id: 'mun', - name: 'Mun', - kind: 'moon', - radius: 200_000, - sphereOfInfluence: 2_429_559, - mu: 6.514e10, - rotationPeriod: 138_984, - axialTilt: 0, - color: '#aaa', - parent: 'kerbin', - sma: 12_000_000, - }), - ]; - setBodies(mockBodies); - setUt(4_700_000); - - // Try to fetch real state if API is reachable - fetch(`${API_URL}/api/v1/state`) - .then((r) => (r.ok ? r.json() : null)) - .then((j) => { - if (j?.data?.bodies?.length) { - setBodies(j.data.bodies); - setUt(j.data.ut); - } - }) - .catch((e) => setError(String(e))); - }, []); - - return { bodies, ut, setUt, error }; -} - -function makeBody(opts: { - id: string; - name: string; - kind: CelestialBody['kind']; - radius: number; - sphereOfInfluence: number; - mu: number; - rotationPeriod: number; - axialTilt: number; - color: string; - parent: string | null; - sma: number; -}): CelestialBody { - return { - id: opts.id, - name: opts.name, - kind: opts.kind, - parentId: opts.parent, - radius: opts.radius, - sphereOfInfluence: opts.sphereOfInfluence, - gravitationalParameter: opts.mu, - rotationPeriod: opts.rotationPeriod, - axialTilt: opts.axialTilt, - orbit: { - semiMajorAxis: opts.sma, - eccentricity: 0, - inclination: 0, - longitudeOfAscendingNode: 0, - argumentOfPeriapsis: 0, - meanAnomalyAtEpoch: Math.random() * Math.PI * 2, - epoch: 0, +/** Fallback in-memory snapshot so the scene has something to render + * even before the API is up. Matches the catalog used by the + * mock-telemetry publisher. */ +const FALLBACK_SNAPSHOT: UniverseSnapshot = { + ut: 0, + capturedAt: new Date(0).toISOString(), + activeVesselId: null, + bodies: [ + { + id: 'kerbol', + name: 'Kerbol', + kind: 'star', + parentId: null, + radius: 261_600_000, + sphereOfInfluence: 1e30, + gravitationalParameter: 1.172332794e18, + rotationPeriod: 432_000, + axialTilt: 0, + orbit: { + semiMajorAxis: 0, + eccentricity: 0, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0, + epoch: 0, + }, }, - }; -} - -function Scene({ bodies, ut }: { bodies: CelestialBody[]; ut: number }) { - const mountRef = useRef(null); - - useEffect(() => { - const mount = mountRef.current; - if (!mount) return; - - const width = mount.clientWidth; - const height = mount.clientHeight; - - const scene = new THREE.Scene(); - scene.background = new THREE.Color(0x000005); - - const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1e12); - camera.position.set(0, 5e9, 1.5e10); - camera.lookAt(0, 0, 0); - - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setSize(width, height); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - mount.appendChild(renderer.domElement); - - // Lights - scene.add(new THREE.AmbientLight(0x404040, 0.4)); - const sunLight = new THREE.PointLight(0xffffff, 2, 0, 0); - scene.add(sunLight); - - // Build spheres + orbit lines per body - const meshById = new Map(); - const orbitGroup = new THREE.Group(); - scene.add(orbitGroup); - - for (const body of bodies) { - if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) { - // Star: render but skip orbit - if (body.kind === 'star') { - const geo = new THREE.SphereGeometry(Math.max(body.radius, 1e8), 32, 16); - const mat = new THREE.MeshBasicMaterial({ color: 0xffcc33 }); - const mesh = new THREE.Mesh(geo, mat); - scene.add(mesh); - meshById.set(body.id, mesh); - } - continue; - } - - // Sphere — small planets/moons get a minimum size so they're visible - const displayRadius = Math.max(body.radius, 1e6); - const geo = new THREE.SphereGeometry(displayRadius, 32, 16); - const mat = new THREE.MeshPhongMaterial({ - color: bodyColor(body.id), - emissive: 0x111111, - }); - const mesh = new THREE.Mesh(geo, mat); - scene.add(mesh); - meshById.set(body.id, mesh); - - // Orbit line - const points = sampleOrbit(body.orbit, body.gravitationalParameter, 256); - const positions = new Float32Array(points.length * 3); - points.forEach((p, i) => { - positions[i * 3] = p.x; - positions[i * 3 + 1] = p.y; - positions[i * 3 + 2] = p.z; - }); - const lineGeo = new THREE.BufferGeometry(); - lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - const lineMat = new THREE.LineBasicMaterial({ - color: bodyColor(body.id), - opacity: 0.6, - transparent: true, - }); - orbitGroup.add(new THREE.LineLoop(lineGeo, lineMat)); - } - - // Resize - const onResize = () => { - if (!mount) return; - const w = mount.clientWidth; - const h = mount.clientHeight; - camera.aspect = w / h; - camera.updateProjectionMatrix(); - renderer.setSize(w, h); - }; - window.addEventListener('resize', onResize); - - // Animation loop: place each body at the current propagated position - let raf = 0; - const render = () => { - for (const body of bodies) { - if (body.sphereOfInfluence === 0 || body.sphereOfInfluence === Infinity) continue; - const parent = bodies.find((b) => b.id === body.parentId); - if (!parent) continue; - const pos = positionAt(body.orbit, parent.gravitationalParameter, ut); - const mesh = meshById.get(body.id); - if (mesh) mesh.position.set(pos.x, pos.y, pos.z); - } - renderer.render(scene, camera); - raf = requestAnimationFrame(render); - }; - render(); - - return () => { - cancelAnimationFrame(raf); - window.removeEventListener('resize', onResize); - renderer.dispose(); - mount.removeChild(renderer.domElement); - }; - }, [bodies, ut]); - - return
; -} - -function bodyColor(id: string): number { - // Map common body names to colors; default to white - const map: Record = { - kerbol: 0xffcc33, - kerbin: 0x3a7d8c, - mun: 0xaaaaaa, - minmus: 0x997a66, - duna: 0xc46030, - ike: 0x776655, - eve: 0x6b4ea0, - gilly: 0x665544, - jool: 0xa55a2a, - laythe: 0x4a6da0, - vall: 0x665544, - tylo: 0x997a66, - bop: 0x444444, - pol: 0x333333, - moho: 0x664433, - eeloo: 0xeeeeff, - }; - return map[id] ?? 0xffffff; -} + { + 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.05, + inclination: 0, + longitudeOfAscendingNode: 0, + argumentOfPeriapsis: 0, + meanAnomalyAtEpoch: 0.7, + epoch: 0, + }, + }, + { + 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: 1.0, + epoch: 0, + }, + }, + ], + vessels: [], + groundStations: [], +}; 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 + const { snapshot, status, lastUpdate, messageCount, error } = useLiveState(API_URL); + const liveUt = snapshot?.ut ?? 0; + // Time controls + const [displayUt, setDisplayUt] = useState(liveUt); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(1); + + // Selection + const [selectedId, setSelectedId] = useState(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(() => { if (!playing) return; - const id = setInterval(() => setUt((u) => u + speed), 1000); + const id = setInterval(() => { + setDisplayUt((u) => u + speed); + }, 1000); return () => clearInterval(id); }, [playing, speed]); + const displaySnapshot: UniverseSnapshot = snapshot ?? FALLBACK_SNAPSHOT; + const vesselCount = displaySnapshot.vessels.length; + const bodyCount = displaySnapshot.bodies.length; + return (
- + -
+ + setPlaying((p) => !p)} + onSpeed={(s) => { + setSpeed(s); + if (s > 0) setPlaying(true); }} - > -
- Kerbal RT - - {error ? 'API offline' : `${bodies.length} bodies`} - + onReverse={() => setSpeed((s) => -s)} + onReset={() => { + setDisplayUt(liveUt); + setPlaying(true); + setSpeed(1); + setSelectedId(null); + }} + onScrub={(u) => { + setDisplayUt(u); + setPlaying(false); + }} + /> + + + + setShowPlanetOrbits((v) => !v)} + onToggleMoon={() => setShowMoonOrbits((v) => !v)} + onToggleVessel={() => setShowVesselOrbits((v) => !v)} + /> + + {error && ( +
+ {error}
-
UT {formatUtAsKspDate(ut)}
-
- - - {[1, 60, 3600, 86400].map((s) => ( - - ))} -
-
+ )}
- Phase 0 skeleton · Three.js + Phase 2 — live map · mock-telemetry driving the WS
); diff --git a/apps/live-map/src/hooks/useLiveState.ts b/apps/live-map/src/hooks/useLiveState.ts new file mode 100644 index 0000000..87f723a --- /dev/null +++ b/apps/live-map/src/hooks/useLiveState.ts @@ -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(null); + const [status, setStatus] = useState('connecting'); + const [lastUpdate, setLastUpdate] = useState(null); + const [messageCount, setMessageCount] = useState(0); + const [error, setError] = useState(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 }; +} diff --git a/apps/live-map/src/panels/FocusPanel.tsx b/apps/live-map/src/panels/FocusPanel.tsx new file mode 100644 index 0000000..87aecbd --- /dev/null +++ b/apps/live-map/src/panels/FocusPanel.tsx @@ -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 ( +
+
+ Orbits +
+ + + +
+ ); +} diff --git a/apps/live-map/src/panels/StatusPill.tsx b/apps/live-map/src/panels/StatusPill.tsx new file mode 100644 index 0000000..30e178b --- /dev/null +++ b/apps/live-map/src/panels/StatusPill.tsx @@ -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 ( +
+ {label} + {bodyCount} bodies + {vesselCount} vessels + {messageCount} msgs +
+ ); +} diff --git a/apps/live-map/src/panels/TimeControls.tsx b/apps/live-map/src/panels/TimeControls.tsx new file mode 100644 index 0000000..5486e98 --- /dev/null +++ b/apps/live-map/src/panels/TimeControls.tsx @@ -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 ( +
+
+ + + +
+ {SPEEDS.map((s) => ( + + ))} +
+
+ UT + {formatKspTime(ut)} + ) => onScrub(Number(e.target.value))} + style={{ flex: 1 }} + /> + + {behindLabel} + +
+
+ ); +} + +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`; +} diff --git a/apps/live-map/src/panels/VesselList.tsx b/apps/live-map/src/panels/VesselList.tsx new file mode 100644 index 0000000..7cf8fd3 --- /dev/null +++ b/apps/live-map/src/panels/VesselList.tsx @@ -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 ( +
+
+ Vessels ({vessels.length}) +
+ {sorted.length === 0 ? ( +
No vessels yet.
+ ) : ( + sorted.map((v) => { + const color = vesselColor(v.owner); + const isActive = selectedId === v.id; + return ( +
onSelect(isActive ? null : v.id)} + title={v.id} + > + +
+
+ {v.name} +
+
+ {v.owner ?? '—'} · {v.situation.toLowerCase()} · {v.referenceBodyId} +
+
+
+ ); + }) + )} +
+ ); +} diff --git a/apps/live-map/src/scene/Scene.tsx b/apps/live-map/src/scene/Scene.tsx new file mode 100644 index 0000000..71725be --- /dev/null +++ b/apps/live-map/src/scene/Scene.tsx @@ -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; + vesselMeshes: Map; + orbitLines: Map; + mount: HTMLDivElement; + raf: number; +} + +const ORBIT_OPACITY: Record = { + 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(null); + const refsRef = useRef(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
; +} + +// ─── 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; +} diff --git a/apps/live-map/src/scene/color.ts b/apps/live-map/src/scene/color.ts new file mode 100644 index 0000000..1416133 --- /dev/null +++ b/apps/live-map/src/scene/color.ts @@ -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 = { + 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 = { + KASA: 0x44aaff, // blue + SPES: 0xff6644, // orange + }; + return map[owner ?? ''] ?? 0xcccccc; +} diff --git a/apps/live-map/src/scene/layout.ts b/apps/live-map/src/scene/layout.ts new file mode 100644 index 0000000..7e6356d --- /dev/null +++ b/apps/live-map/src/scene/layout.ts @@ -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; diff --git a/apps/live-map/tests/scene.test.ts b/apps/live-map/tests/scene.test.ts new file mode 100644 index 0000000..517c1ec --- /dev/null +++ b/apps/live-map/tests/scene.test.ts @@ -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); + }); +}); diff --git a/apps/live-map/vitest.config.ts b/apps/live-map/vitest.config.ts new file mode 100644 index 0000000..b0e95a5 --- /dev/null +++ b/apps/live-map/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1232efa..1a68e28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: vite: specifier: ^5.4.6 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: dependencies: