Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@kerbal-rt/orbital-math",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pure functions for keplerian orbit propagation, occultation, transfer windows",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo 'no linter yet'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kerbal-rt/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^2.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { solveKepler } from './kepler.js';
|
||||
export {
|
||||
meanMotion,
|
||||
propagateToEpoch,
|
||||
positionAt,
|
||||
sampleOrbit,
|
||||
} from './propagate.js';
|
||||
export { shadowFraction } from './occultation.js';
|
||||
export { phaseAngle, hohmannDeltaV, findTransferWindows } from './transfer.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Solve Kepler's equation: M = E - e * sin(E)
|
||||
* for the eccentric anomaly E, given mean anomaly M and eccentricity e.
|
||||
*
|
||||
* Uses Newton-Raphson iteration with a sane initial guess.
|
||||
* Converges in ~5 iterations for any reasonable e (< 0.9).
|
||||
* For near-parabolic / hyperbolic orbits, use a different solver.
|
||||
*/
|
||||
export function solveKepler(meanAnomaly: number, eccentricity: number): number {
|
||||
// Normalize M to [-π, π] for faster convergence.
|
||||
const TWO_PI = Math.PI * 2;
|
||||
let M = meanAnomaly % TWO_PI;
|
||||
if (M > Math.PI) M -= TWO_PI;
|
||||
if (M < -Math.PI) M += TWO_PI;
|
||||
|
||||
// Initial guess: E₀ = M + e·sin(M) is a good first order approximation.
|
||||
let E = M + eccentricity * Math.sin(M);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const f = E - eccentricity * Math.sin(E) - M;
|
||||
const fp = 1 - eccentricity * Math.cos(E);
|
||||
const dE = f / fp;
|
||||
E -= dE;
|
||||
if (Math.abs(dE) < 1e-12) break;
|
||||
}
|
||||
|
||||
return E;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Geometric occultation: given a position (relative to a body's center)
|
||||
* and the radii of the occluder (R1) and the body the observer is on (R2),
|
||||
* is the observer currently in shadow?
|
||||
*
|
||||
* Used for both:
|
||||
* - "is this vessel in the planet's shadow?" (R1 = planet radius, R2 ≈ 0)
|
||||
* - "is this ground station blocked by the local terrain?" (R1 = planet, R2 = earth station)
|
||||
*
|
||||
* Returns the fraction (0..1) of the line of sight to the sun that is
|
||||
* occluded. 0 = full sun, 1 = total eclipse.
|
||||
*
|
||||
* Note: the canonical way to do this is to compute the half-angle between
|
||||
* the sun and the occluding body as seen by the observer. We treat the
|
||||
* sun as effectively at infinity (parallel rays) which is fine for KSP
|
||||
* since Kerbol is the system root and we're never going to need parallax
|
||||
* precision at this scale.
|
||||
*/
|
||||
export function shadowFraction(
|
||||
observerToSun: { x: number; y: number; z: number },
|
||||
occluderToObserver: { x: number; y: number; z: number },
|
||||
occluderRadius: number,
|
||||
): number {
|
||||
// Vector from observer to sun, normalized
|
||||
const sunDist = Math.hypot(observerToSun.x, observerToSun.y, observerToSun.z);
|
||||
if (sunDist === 0) return 0;
|
||||
const sx = observerToSun.x / sunDist;
|
||||
const sy = observerToSun.y / sunDist;
|
||||
const sz = observerToSun.z / sunDist;
|
||||
|
||||
// Project occluder center onto the sun-direction line
|
||||
const proj = occluderToObserver.x * sx + occluderToObserver.y * sy + occluderToObserver.z * sz;
|
||||
if (proj >= 0) {
|
||||
// Occluder is behind the observer relative to the sun → no eclipse
|
||||
return 0;
|
||||
}
|
||||
// Perpendicular distance from occluder center to sun ray
|
||||
const px = occluderToObserver.x - proj * sx;
|
||||
const py = occluderToObserver.y - proj * sy;
|
||||
const pz = occluderToObserver.z - proj * sz;
|
||||
const perpDist = Math.hypot(px, py, pz);
|
||||
|
||||
if (perpDist >= occluderRadius) return 0;
|
||||
// Approximate chord length through the occluder disc
|
||||
const halfChord = Math.sqrt(occluderRadius * occluderRadius - perpDist * perpDist);
|
||||
// Approximate the angular size of the sun as seen from the occluder
|
||||
// vs the angular size of the occluder; we use 1.0 for the sun
|
||||
// (i.e. effectively point source) — good enough for visualization.
|
||||
// For a "fraction in shadow" treat the occluder disc as fully shadowing
|
||||
// when perpDist + halfChord reaches the observer; that simplifies to
|
||||
// perpDist < occluderRadius which we already check.
|
||||
return Math.min(1, 1 - perpDist / occluderRadius);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { KeplerianElements, CelestialBody } from '@kerbal-rt/shared-types';
|
||||
import { solveKepler } from './kepler.js';
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* Compute the mean motion n (rad/s) from semi-major axis and the
|
||||
* gravitational parameter μ of the central body.
|
||||
*
|
||||
* n = sqrt(μ / a^3)
|
||||
*/
|
||||
export function meanMotion(semiMajorAxis: number, mu: number): number {
|
||||
return Math.sqrt(mu / Math.pow(semiMajorAxis, 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate Keplerian elements forward in time to a new epoch.
|
||||
*
|
||||
* Only `meanAnomalyAtEpoch` and `epoch` change. (For a fully accurate
|
||||
* J2-perturbed propagation you'd also adjust RAAN and argPe due to
|
||||
* precession, but for visualization purposes this is plenty.)
|
||||
*/
|
||||
export function propagateToEpoch(
|
||||
elements: KeplerianElements,
|
||||
mu: number,
|
||||
newEpoch: number,
|
||||
): KeplerianElements {
|
||||
const dt = newEpoch - elements.epoch;
|
||||
const n = meanMotion(elements.semiMajorAxis, mu);
|
||||
return {
|
||||
...elements,
|
||||
epoch: newEpoch,
|
||||
meanAnomalyAtEpoch: elements.meanAnomalyAtEpoch + n * dt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate an orbit to a target UT and return the position in the
|
||||
* parent body's inertial frame.
|
||||
*
|
||||
* For an ellipse (e < 1):
|
||||
* 1. Compute mean anomaly at target time
|
||||
* 2. Solve Kepler for eccentric anomaly E
|
||||
* 3. Position in perifocal frame: (a(cosE - e), a√(1-e²) sinE, 0)
|
||||
* 4. Rotate by ω, i, Ω to get the inertial frame
|
||||
*
|
||||
* Returns the cartesian position vector (m).
|
||||
*/
|
||||
export function positionAt(
|
||||
elements: KeplerianElements,
|
||||
mu: number,
|
||||
ut: number,
|
||||
): { x: number; y: number; z: number } {
|
||||
const e = elements.eccentricity;
|
||||
const a = elements.semiMajorAxis;
|
||||
const i = elements.inclination;
|
||||
const O = elements.longitudeOfAscendingNode; // RAAN
|
||||
const w = elements.argumentOfPeriapsis;
|
||||
|
||||
// Mean anomaly at the requested time
|
||||
const n = meanMotion(a, mu);
|
||||
const M = elements.meanAnomalyAtEpoch + n * (ut - elements.epoch);
|
||||
|
||||
// Eccentric anomaly
|
||||
const E = solveKepler(M, e);
|
||||
|
||||
// True anomaly
|
||||
// cos ν = (cos E - e) / (1 - e cos E)
|
||||
// sin ν = (√(1-e²) sin E) / (1 - e cos E)
|
||||
const cosE = Math.cos(E);
|
||||
const sinE = Math.sin(E);
|
||||
const denom = 1 - e * cosE;
|
||||
const cosNu = (cosE - e) / denom;
|
||||
const sinNu = (Math.sqrt(1 - e * e) * sinE) / denom;
|
||||
|
||||
// Distance
|
||||
const r = a * (1 - e * cosE);
|
||||
|
||||
// Position in perifocal frame (P, Q, W)
|
||||
const xP = r * cosNu;
|
||||
const yQ = r * sinNu;
|
||||
|
||||
// Rotation matrix from perifocal to inertial: Rz(-Ω) · Rx(-i) · Rz(-ω)
|
||||
// Applied to (xP, yQ, 0):
|
||||
const cosO = Math.cos(O);
|
||||
const sinO = Math.sin(O);
|
||||
const cosi = Math.cos(i);
|
||||
const sini = Math.sin(i);
|
||||
const cosw = Math.cos(w);
|
||||
const sinw = Math.sin(w);
|
||||
|
||||
const x =
|
||||
(cosO * cosw - sinO * sinw * cosi) * xP + (-cosO * sinw - sinO * cosw * cosi) * yQ;
|
||||
const y =
|
||||
(sinO * cosw + cosO * sinw * cosi) * xP + (-sinO * sinw + cosO * cosw * cosi) * yQ;
|
||||
const z = sinw * sini * xP + cosw * sini * yQ;
|
||||
|
||||
return { x, y, z };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a full orbit as a list of cartesian points. Useful for drawing
|
||||
* the orbit line. Returns `steps` evenly-spaced points in true anomaly
|
||||
* around the conic.
|
||||
*/
|
||||
export function sampleOrbit(
|
||||
elements: KeplerianElements,
|
||||
mu: number,
|
||||
steps: number = 128,
|
||||
): { x: number; y: number; z: number }[] {
|
||||
const points: { x: number; y: number; z: number }[] = [];
|
||||
const e = elements.eccentricity;
|
||||
const a = elements.semiMajorAxis;
|
||||
const i = elements.inclination;
|
||||
const O = elements.longitudeOfAscendingNode;
|
||||
const w = elements.argumentOfPeriapsis;
|
||||
|
||||
const cosO = Math.cos(O);
|
||||
const sinO = Math.sin(O);
|
||||
const cosi = Math.cos(i);
|
||||
const sini = Math.sin(i);
|
||||
const cosw = Math.cos(w);
|
||||
const sinw = Math.sin(w);
|
||||
|
||||
for (let k = 0; k < steps; k++) {
|
||||
// True anomaly uniformly from 0 to 2π
|
||||
const nu = (k / steps) * TWO_PI;
|
||||
const cosNu = Math.cos(nu);
|
||||
const sinNu = Math.sin(nu);
|
||||
const r = (a * (1 - e * e)) / (1 + e * cosNu);
|
||||
const xP = r * cosNu;
|
||||
const yQ = r * sinNu;
|
||||
const x =
|
||||
(cosO * cosw - sinO * sinw * cosi) * xP + (-cosO * sinw - sinO * cosw * cosi) * yQ;
|
||||
const y =
|
||||
(sinO * cosw + cosO * sinw * cosi) * xP + (-sinO * sinw + cosO * cosw * cosi) * yQ;
|
||||
const z = sinw * sini * xP + cosw * sini * yQ;
|
||||
points.push({ x, y, z });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { KeplerianElements, CelestialBody } from '@kerbal-rt/shared-types';
|
||||
import { meanMotion } from './propagate.js';
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* Compute the phase angle between two orbiting bodies at a given UT.
|
||||
* Phase angle is the angle at the central body between the two bodies,
|
||||
* measured in the direction of motion.
|
||||
*/
|
||||
export function phaseAngle(
|
||||
a: KeplerianElements,
|
||||
aMu: number,
|
||||
b: KeplerianElements,
|
||||
bMu: number,
|
||||
ut: number,
|
||||
): number {
|
||||
const nA = meanMotion(a.semiMajorAxis, aMu);
|
||||
const nB = meanMotion(b.semiMajorAxis, bMu);
|
||||
const MA = a.meanAnomalyAtEpoch + nA * (ut - a.epoch);
|
||||
const MB = b.meanAnomalyAtEpoch + nB * (ut - b.epoch);
|
||||
let phase = MB - MA;
|
||||
// Normalize to [0, 2π)
|
||||
phase = phase % TWO_PI;
|
||||
if (phase < 0) phase += TWO_PI;
|
||||
return phase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Very rough Hohmann transfer Δv estimate between two coplanar circular
|
||||
* orbits. Good enough for a "transfer window" calculator; for precise
|
||||
* numbers you'd solve Lambert's problem.
|
||||
*
|
||||
* Returns the total Δv (m/s) for the two-burn transfer.
|
||||
*/
|
||||
export function hohmannDeltaV(
|
||||
r1: number,
|
||||
r2: number,
|
||||
mu: number,
|
||||
): { dv1: number; dv2: number; total: number; transferTime: number } {
|
||||
// Semi-major axis of transfer ellipse
|
||||
const aTransfer = (r1 + r2) / 2;
|
||||
// Circular velocities
|
||||
const v1 = Math.sqrt(mu / r1);
|
||||
const v2 = Math.sqrt(mu / r2);
|
||||
// Velocities on transfer ellipse at periapsis (r1) and apoapsis (r2)
|
||||
const vt1 = Math.sqrt(mu * (2 / r1 - 1 / aTransfer));
|
||||
const vt2 = Math.sqrt(mu * (2 / r2 - 1 / aTransfer));
|
||||
const dv1 = Math.abs(vt1 - v1);
|
||||
const dv2 = Math.abs(v2 - vt2);
|
||||
const transferTime = Math.PI * Math.sqrt(Math.pow(aTransfer, 3) / mu);
|
||||
return { dv1, dv2, total: dv1 + dv2, transferTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next N transfer windows from `from` body to `to` body,
|
||||
* defined as times when the phase angle is within ±tolerance of the
|
||||
* ideal Hohmann transfer phase.
|
||||
*
|
||||
* Ideal phase = π · (1 - (1/2) · ((r2/r1)^(3/2) + 1)^(-2/3))
|
||||
* (this is the classic approximation for the case r2 > r1)
|
||||
*
|
||||
* We just scan forward from `ut` until we've found `n` windows.
|
||||
*/
|
||||
export function findTransferWindows(
|
||||
from: { elements: KeplerianElements; mu: number },
|
||||
to: { elements: KeplerianElements; mu: number },
|
||||
ut: number,
|
||||
options: { count?: number; toleranceRad?: number; maxSearchTime?: number } = {},
|
||||
): { ut: number; phaseAngle: number }[] {
|
||||
const { count = 3, toleranceRad = 0.15, maxSearchTime = 5 * 365 * 24 * 3600 } = options;
|
||||
const r1 = from.elements.semiMajorAxis;
|
||||
const r2 = to.elements.semiMajorAxis;
|
||||
const ratio = Math.pow(r2 / r1, 1.5);
|
||||
const idealPhase = Math.PI * (1 - Math.pow(1 + ratio, -2 / 3) * 0.5);
|
||||
// For inner→outer transfers; for outer→inner the phase wraps differently.
|
||||
// For simplicity, accept either ±(2π - idealPhase) too.
|
||||
const phases = [idealPhase, TWO_PI - idealPhase];
|
||||
|
||||
const results: { ut: number; phaseAngle: number }[] = [];
|
||||
const stepSec = 3600; // 1h steps for the coarse scan
|
||||
const nA = meanMotion(from.elements.semiMajorAxis, from.mu);
|
||||
|
||||
for (let t = ut; t < ut + maxSearchTime && results.length < count; t += stepSec) {
|
||||
const phase = phaseAngle(from.elements, from.mu, to.elements, to.mu, t);
|
||||
for (const target of phases) {
|
||||
const diff = Math.abs(((phase - target + Math.PI) % TWO_PI) - Math.PI);
|
||||
if (diff < toleranceRad) {
|
||||
results.push({ ut: t, phaseAngle: phase });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { solveKepler } from '../src/kepler.js';
|
||||
|
||||
describe('solveKepler', () => {
|
||||
it('returns 0 for M=0, e=0', () => {
|
||||
expect(solveKepler(0, 0)).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it('returns M for e=0 (circular orbit)', () => {
|
||||
const M = 1.234;
|
||||
expect(solveKepler(M, 0)).toBeCloseTo(M, 10);
|
||||
});
|
||||
|
||||
it('solves low-eccentricity orbits', () => {
|
||||
// E ≈ M + e·sin(M) is the first-order correction
|
||||
const e = 0.1;
|
||||
const M = 0.5;
|
||||
const E = solveKepler(M, e);
|
||||
const residual = E - e * Math.sin(E) - M;
|
||||
expect(residual).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it('solves high-eccentricity orbits (e=0.9)', () => {
|
||||
const e = 0.9;
|
||||
const M = 1.0;
|
||||
const E = solveKepler(M, e);
|
||||
const residual = E - e * Math.sin(E) - M;
|
||||
expect(residual).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it('normalizes M to [-π, π]', () => {
|
||||
// 3π and π are equivalent; should give the same E.
|
||||
const e = 0.3;
|
||||
expect(solveKepler(3 * Math.PI, e)).toBeCloseTo(solveKepler(Math.PI, e), 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@kerbal-rt/shared-types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "TypeScript types shared between API, hub, and live-map",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo 'no linter yet'",
|
||||
"test": "echo 'no tests yet'",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Shared types used across the API, hub, and live-map frontends.
|
||||
*
|
||||
* Everything that crosses an HTTP or WebSocket boundary lives here.
|
||||
* Nothing in this package should import from anywhere else in the
|
||||
* monorepo — keep it a leaf.
|
||||
*/
|
||||
|
||||
// ─── Celestial bodies ──────────────────────────────────────────────────────
|
||||
|
||||
export type BodyKind = 'star' | 'planet' | 'moon' | 'asteroid' | 'comet';
|
||||
|
||||
/**
|
||||
* Keplerian orbital elements at a given epoch.
|
||||
* Angles are in radians, distances in meters.
|
||||
*/
|
||||
export interface KeplerianElements {
|
||||
/** Semi-major axis (m) */
|
||||
semiMajorAxis: number;
|
||||
/** Eccentricity (0=circle, 0..1=ellipse, 1+=parabolic/hyperbolic) */
|
||||
eccentricity: number;
|
||||
/** Inclination (rad) */
|
||||
inclination: number;
|
||||
/** Longitude of ascending node (rad) */
|
||||
longitudeOfAscendingNode: number;
|
||||
/** Argument of periapsis (rad) */
|
||||
argumentOfPeriapsis: number;
|
||||
/** Mean anomaly at epoch (rad) */
|
||||
meanAnomalyAtEpoch: number;
|
||||
/** Epoch (UT seconds) */
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
export interface CelestialBody {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: BodyKind;
|
||||
parentId: string | null;
|
||||
radius: number; // m
|
||||
/** Sphere of influence (m); 0 or Infinity for the system root (the star) */
|
||||
sphereOfInfluence: number;
|
||||
/** Gravitational parameter μ = G·M (m^3/s^2) */
|
||||
gravitationalParameter: number;
|
||||
/** Rotation period (s); 0 for tidally-locked or N/A */
|
||||
rotationPeriod: number;
|
||||
/** Axial tilt (rad) */
|
||||
axialTilt: number;
|
||||
/** Keplerian elements relative to the parent body */
|
||||
orbit: KeplerianElements;
|
||||
}
|
||||
|
||||
// ─── Vessels ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type VesselSituation =
|
||||
| 'ORBITING'
|
||||
| 'ESCAPING'
|
||||
| 'LANDED'
|
||||
| 'SPLASHED'
|
||||
| 'PRELAUNCH'
|
||||
| 'FLYING'
|
||||
| 'SUB_ORBITAL'
|
||||
| 'DOCKED'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export type VesselStatus = 'ACTIVE' | 'DECOMMISSIONED' | 'LOST';
|
||||
|
||||
export interface Vessel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string | null; // e.g. "Probe", "Booster", "Station"
|
||||
owner: string | null; // e.g. "KASA", "SPES"
|
||||
situation: VesselSituation;
|
||||
status: VesselStatus;
|
||||
/** Keplerian elements relative to the body the vessel is currently orbiting */
|
||||
orbit: KeplerianElements;
|
||||
/** ID of the body the vessel's orbit is currently referenced to */
|
||||
referenceBodyId: string;
|
||||
createdAt: string; // ISO 8601
|
||||
retiredAt: string | null;
|
||||
}
|
||||
|
||||
export interface GroundStation {
|
||||
id: string;
|
||||
name: string;
|
||||
bodyId: string;
|
||||
lat: number; // deg
|
||||
lon: number; // deg
|
||||
alt: number; // m above sea level
|
||||
}
|
||||
|
||||
// ─── State snapshots ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A full universe snapshot, published by the KSP telemetry bridge
|
||||
* on each tick. Frontends use this to render the live map and to
|
||||
* maintain client-side state.
|
||||
*/
|
||||
export interface UniverseSnapshot {
|
||||
/** KSP universal time, seconds from epoch 0 (year 1, day 1) */
|
||||
ut: number;
|
||||
/** Wall-clock time when this snapshot was captured (ISO 8601) */
|
||||
capturedAt: string;
|
||||
activeVesselId: string | null;
|
||||
bodies: CelestialBody[];
|
||||
vessels: Vessel[];
|
||||
groundStations: GroundStation[];
|
||||
}
|
||||
|
||||
// ─── Missions & events ─────────────────────────────────────────────────────
|
||||
|
||||
export type MissionEventKind =
|
||||
| 'LAUNCH'
|
||||
| 'BURN'
|
||||
| 'FLYBY'
|
||||
| 'LANDING'
|
||||
| 'DOCKING'
|
||||
| 'EVA'
|
||||
| 'ECLIPSE'
|
||||
| 'OPERATION'
|
||||
| 'OTHER';
|
||||
|
||||
export interface MissionEvent {
|
||||
id: number;
|
||||
mission: string;
|
||||
vesselId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
scheduledAt: string; // ISO 8601 wall-clock
|
||||
utAtEvent: number | null; // KSP UT at the scheduled time, if known
|
||||
durationSeconds: number | null;
|
||||
kind: MissionEventKind;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Mission {
|
||||
id: string;
|
||||
name: string;
|
||||
agency: string; // "KASA" | "SPES" | other
|
||||
description: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
vesselIds: string[];
|
||||
}
|
||||
|
||||
// ─── Media ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MediaKind = 'image' | 'youtube';
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
kind: MediaKind;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
caption: string | null;
|
||||
tags: string[];
|
||||
missionId: string | null;
|
||||
vesselId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ─── API envelope ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiError {
|
||||
error: true;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiOk<T> {
|
||||
error: false;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = ApiOk<T> | ApiError;
|
||||
|
||||
export * from './schemas.js';
|
||||
|
||||
// ─── WebSocket protocol ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Messages pushed from the API to live-map / hub clients over `/api/v1/live`.
|
||||
* Clients should ignore unknown message types (forward-compat).
|
||||
*/
|
||||
export type LiveMessage =
|
||||
| { type: 'snapshot'; snapshot: UniverseSnapshot }
|
||||
| { type: 'vessel_added'; vessel: Vessel }
|
||||
| { type: 'vessel_updated'; vessel: Vessel }
|
||||
| { type: 'vessel_removed'; vesselId: string }
|
||||
| { type: 'event_new'; event: MissionEvent }
|
||||
| { type: 'event_updated'; event: MissionEvent }
|
||||
| { type: 'event_removed'; eventId: number }
|
||||
| { type: 'mission_update'; mission: Mission }
|
||||
| { type: 'ping'; serverTime: string };
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The KSP epoch: year 1, day 1, 00:00:00 UT, in seconds. */
|
||||
export const KSP_EPOCH_UT = 0;
|
||||
|
||||
/** Default polling cadence for clients that don't open a WebSocket. */
|
||||
export const DEFAULT_SNAPSHOT_TTL_SECONDS = 30;
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Zod schemas for runtime validation of API payloads. These mirror the
|
||||
* TypeScript types in `index.ts` and are re-exported so the API can
|
||||
* validate ingest payloads and the frontends can validate responses
|
||||
* if they want stronger guarantees.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export const KeplerianElementsSchema = z.object({
|
||||
semiMajorAxis: z.number().nonnegative(),
|
||||
eccentricity: z.number().min(0),
|
||||
inclination: z.number(),
|
||||
longitudeOfAscendingNode: z.number(),
|
||||
argumentOfPeriapsis: z.number(),
|
||||
meanAnomalyAtEpoch: z.number(),
|
||||
epoch: z.number(),
|
||||
});
|
||||
|
||||
export const CelestialBodySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.enum(['star', 'planet', 'moon', 'asteroid', 'comet']),
|
||||
parentId: z.string().nullable(),
|
||||
radius: z.number().nonnegative(),
|
||||
sphereOfInfluence: z.number().nonnegative(),
|
||||
gravitationalParameter: z.number().nonnegative(),
|
||||
rotationPeriod: z.number().nonnegative(),
|
||||
axialTilt: z.number(),
|
||||
orbit: KeplerianElementsSchema,
|
||||
});
|
||||
|
||||
export const VesselSituationSchema = z.enum([
|
||||
'ORBITING',
|
||||
'ESCAPING',
|
||||
'LANDED',
|
||||
'SPLASHED',
|
||||
'PRELAUNCH',
|
||||
'FLYING',
|
||||
'SUB_ORBITAL',
|
||||
'DOCKED',
|
||||
'UNKNOWN',
|
||||
]);
|
||||
|
||||
export const VesselStatusSchema = z.enum(['ACTIVE', 'DECOMMISSIONED', 'LOST']);
|
||||
|
||||
export const VesselSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string().nullable(),
|
||||
owner: z.string().nullable(),
|
||||
situation: VesselSituationSchema,
|
||||
status: VesselStatusSchema,
|
||||
orbit: KeplerianElementsSchema,
|
||||
referenceBodyId: z.string(),
|
||||
createdAt: z.string(),
|
||||
retiredAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const GroundStationSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
bodyId: z.string(),
|
||||
lat: z.number(),
|
||||
lon: z.number(),
|
||||
alt: z.number(),
|
||||
});
|
||||
|
||||
export const UniverseSnapshotSchema = z.object({
|
||||
ut: z.number(),
|
||||
capturedAt: z.string(),
|
||||
activeVesselId: z.string().nullable(),
|
||||
bodies: z.array(CelestialBodySchema),
|
||||
vessels: z.array(VesselSchema),
|
||||
groundStations: z.array(GroundStationSchema),
|
||||
});
|
||||
|
||||
export const MissionEventSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
mission: z.string(),
|
||||
vesselId: z.string().nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
scheduledAt: z.string(),
|
||||
utAtEvent: z.number().nullable(),
|
||||
durationSeconds: z.number().int().nullable(),
|
||||
kind: z.enum([
|
||||
'LAUNCH',
|
||||
'BURN',
|
||||
'FLYBY',
|
||||
'LANDING',
|
||||
'DOCKING',
|
||||
'EVA',
|
||||
'ECLIPSE',
|
||||
'OPERATION',
|
||||
'OTHER',
|
||||
]),
|
||||
createdBy: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const MediaItemSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
kind: z.enum(['image', 'youtube']),
|
||||
url: z.string().url(),
|
||||
thumbnailUrl: z.string().url().nullable(),
|
||||
caption: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
missionId: z.string().nullable(),
|
||||
vesselId: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@kerbal-rt/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Shared React components for hub + live-map",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo 'no linter yet'",
|
||||
"test": "echo 'no tests yet'",
|
||||
"build": "tsc"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared UI primitives used by both hub and live-map frontends.
|
||||
* Keep this package dependency-free (no Tailwind, no MUI) — apps
|
||||
* style with their own CSS / framework.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export function Pill({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'success' | 'warn' | 'danger' }) {
|
||||
const colorMap = {
|
||||
neutral: 'var(--pill-neutral, #ddd)',
|
||||
success: 'var(--pill-success, #2c5)',
|
||||
warn: 'var(--pill-warn, #fa3)',
|
||||
danger: 'var(--pill-danger, #e44)',
|
||||
} as const;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: 999,
|
||||
background: colorMap[tone],
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, title }: { children: ReactNode; title?: string }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid var(--card-border, #333)',
|
||||
borderRadius: 8,
|
||||
padding: '1rem',
|
||||
background: 'var(--card-bg, rgba(255,255,255,0.03))',
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1rem', fontWeight: 600 }}>{title}</h3>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatUtAsKspDate(ut: number): string {
|
||||
// KSP year 1 day 1 00:00:00 is UT=0. A KSP day is 6 hours, a year is 426 days.
|
||||
const KSP_DAY_SECONDS = 6 * 3600;
|
||||
const KSP_YEAR_DAYS = 426;
|
||||
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 hour = Math.floor(secondsInDay / 3600);
|
||||
const minute = Math.floor((secondsInDay % 3600) / 60);
|
||||
const second = Math.floor(secondsInDay % 60);
|
||||
return `Y${year} D${day} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user