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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user