Phase 2: 3D live map driven by API WebSocket
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
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:
@@ -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;
|
||||
Reference in New Issue
Block a user