Phase 2: 3D live map driven by API WebSocket
CI / Lint, typecheck, test, build (pull_request) Failing after 9s

- apps/live-map/src/hooks/useLiveState.ts: WebSocket subscription
  with exponential-backoff reconnect, polling fallback, status tracking
- apps/live-map/src/scene/Scene.tsx: refactored Three.js scene with
  per-frame orbit propagation, vessel marker color-coding by owner,
  orbit-line visibility tied to focus filters, smooth camera follow
  on selected vessel/body
- apps/live-map/src/scene/layout.ts: bodyPositionAt / vesselPositionAt
  helpers (heliocentric frame, walk up the parent chain), logScale
  helpers for the system view
- apps/live-map/src/scene/color.ts: per-body and per-owner color maps
- apps/live-map/src/panels/TimeControls.tsx: play/pause/reverse/reset
  buttons, ×1/×10/×100/×1k/×10k/×100k speeds, UT scrub slider,
  live-edge indicator (LIVE / Nh behind / Nh ahead)
- apps/live-map/src/panels/VesselList.tsx: vessel sidebar with click-
  to-track; color-coded by owner (KASA=blue, SPES=orange)
- apps/live-map/src/panels/FocusPanel.tsx: planet/moon/vessel orbit
  visibility toggles
- apps/live-map/src/panels/StatusPill.tsx: WS status (LIVE/POLLING/
  OFFLINE/STALE), body + vessel + message counts
- tests/scene.test.ts: 10 tests for layout helpers (periodicity,
  vessel-centered positioning, logScale round-trips)

End-to-end verified: mock publisher → API → live-map WebSocket →
scene re-renders with the new vessel positions and orbits.
This commit is contained in:
Mavis
2026-06-02 19:18:22 +00:00
parent a457b9d96f
commit 9e76ec9328
13 changed files with 1303 additions and 286 deletions
+295
View File
@@ -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;
}
+40
View File
@@ -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;
}
+68
View File
@@ -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;