Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)

This commit is contained in:
Mavis
2026-06-02 15:47:28 +00:00
commit 7b19c54943
59 changed files with 2391 additions and 0 deletions
+26
View File
@@ -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"
}
}
+9
View File
@@ -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';
+28
View File
@@ -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;
}
+53
View File
@@ -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);
}
+141
View File
@@ -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;
}
+95
View File
@@ -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);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+24
View File
@@ -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"
}
}
+203
View File
@@ -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;
+111
View File
@@ -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(),
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
+27
View File
@@ -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"
}
}
+64
View File
@@ -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')}`;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx"
},
"include": ["src/**/*"]
}