Add star-map Angular app, ETL pipeline, and caveman plugin

Angular 3D star map (galaxy/system/body views, Three.js rendering,
navigation store) plus the NASA ETL tooling that builds the star,
exoplanet and solar-system datasets, Playwright e2e suite, and the
cs:caveman Claude Code plugin (command, agent, skill).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-08-03 16:50:10 +02:00
parent 1e1b58b0e9
commit d7e8ea1d4d
99 changed files with 5513 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
import {
meanMotionRadPerDay,
orbitEllipsePoints,
orbitalPeriodDays,
positionAtTrueAnomaly,
propagateOrbit,
resolveOrbitalElements,
solveEccentricAnomaly,
trueAnomalyFromEccentricAnomaly
} from './kepler';
// Earth's actual orbital elements (osculating, ~J2000), used as a real-world reference case.
const EARTH_ELEMENTS = {
semiMajorAxisAu: 1.00000011,
eccentricity: 0.01671022,
inclinationDeg: 0.00005,
longitudeOfAscendingNodeDeg: -11.26064,
argumentOfPeriapsisDeg: 102.94719,
meanAnomalyAtEpochDeg: 100.46435,
epochJd: DEFAULT_EPOCH_JD
};
describe('solveEccentricAnomaly', () => {
it('satisfies Keplers equation for a range of eccentricities', () => {
for (const eccentricity of [0, 0.0167, 0.3, 0.6, 0.9]) {
for (const meanAnomalyRad of [0, 0.5, 1.5, 3.0, 5.5]) {
const e = solveEccentricAnomaly(meanAnomalyRad, eccentricity);
const residual = e - eccentricity * Math.sin(e) - meanAnomalyRad;
// residual is computed against the (possibly un-normalized) input, but the solver
// normalizes internally, so compare against the normalized mean anomaly instead.
const normalizedMeanAnomaly = ((meanAnomalyRad % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
expect(e - eccentricity * Math.sin(e)).toBeCloseTo(normalizedMeanAnomaly, 6);
expect(Number.isFinite(residual)).toBe(true);
}
}
});
});
describe('trueAnomalyFromEccentricAnomaly', () => {
it('returns 0 at periapsis and pi at apoapsis', () => {
expect(trueAnomalyFromEccentricAnomaly(0, 0.3)).toBeCloseTo(0, 9);
expect(trueAnomalyFromEccentricAnomaly(Math.PI, 0.3)).toBeCloseTo(Math.PI, 9);
});
it('matches the eccentric anomaly exactly for a circular orbit', () => {
expect(trueAnomalyFromEccentricAnomaly(1.234, 0)).toBeCloseTo(1.234, 9);
});
});
describe('positionAtTrueAnomaly', () => {
it('places a circular, unrotated orbit at radius = semiMajorAxisAu for every true anomaly', () => {
const circular = resolveOrbitalElements({ semiMajorAxisAu: 2.5, eccentricity: 0 });
for (const trueAnomalyRad of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
const { x, y, z } = positionAtTrueAnomaly(circular, trueAnomalyRad);
expect(Math.sqrt(x * x + y * y + z * z)).toBeCloseTo(2.5, 9);
}
});
it('reaches periapsis distance a*(1-e) and apoapsis distance a*(1+e)', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 10, eccentricity: 0.2 });
const periapsis = positionAtTrueAnomaly(elements, 0);
const apoapsis = positionAtTrueAnomaly(elements, Math.PI);
expect(Math.hypot(periapsis.x, periapsis.y, periapsis.z)).toBeCloseTo(8, 9);
expect(Math.hypot(apoapsis.x, apoapsis.y, apoapsis.z)).toBeCloseTo(12, 9);
});
it('tilts a 90-degree-inclined orbit entirely onto the z axis at true anomaly 90 degrees', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 1, eccentricity: 0, inclinationDeg: 90 });
const { x, y, z } = positionAtTrueAnomaly(elements, Math.PI / 2);
expect(x).toBeCloseTo(0, 9);
expect(y).toBeCloseTo(0, 9);
expect(z).toBeCloseTo(1, 9);
});
});
describe('meanMotionRadPerDay / orbitalPeriodDays', () => {
it('reproduces Earths ~365.25-day year from its semi-major axis', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
expect(period).toBeCloseTo(365.25, 0);
});
it('is the inverse of orbitalPeriodDays', () => {
const n = meanMotionRadPerDay(1, GM_SUN_AU3_PER_DAY2);
const period = orbitalPeriodDays(1, GM_SUN_AU3_PER_DAY2);
expect(n * period).toBeCloseTo(2 * Math.PI, 9);
});
});
describe('propagateOrbit', () => {
it('reduces to the instantaneous position at the elements own epoch', () => {
const eccentricAnomalyRad = solveEccentricAnomaly((EARTH_ELEMENTS.meanAnomalyAtEpochDeg * Math.PI) / 180, EARTH_ELEMENTS.eccentricity);
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, EARTH_ELEMENTS.eccentricity);
const expected = positionAtTrueAnomaly(EARTH_ELEMENTS, trueAnomalyRad);
const actual = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd);
expect(actual.x).toBeCloseTo(expected.x, 9);
expect(actual.y).toBeCloseTo(expected.y, 9);
expect(actual.z).toBeCloseTo(expected.z, 9);
});
it('stays within the periapsis/apoapsis distance bounds after propagating forward a year', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
const { x, y, z } = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + period * 0.37);
const distance = Math.hypot(x, y, z);
const { semiMajorAxisAu: a, eccentricity: e } = EARTH_ELEMENTS;
expect(distance).toBeGreaterThanOrEqual(a * (1 - e) - 1e-6);
expect(distance).toBeLessThanOrEqual(a * (1 + e) + 1e-6);
});
it('returns to (very nearly) the same position after exactly one full orbital period', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
const start = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + 12.3);
const afterOneOrbit = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + 12.3 + period);
expect(afterOneOrbit.x).toBeCloseTo(start.x, 6);
expect(afterOneOrbit.y).toBeCloseTo(start.y, 6);
expect(afterOneOrbit.z).toBeCloseTo(start.z, 6);
});
});
describe('orbitEllipsePoints', () => {
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
const points = orbitEllipsePoints(elements, 64);
expect(points).toHaveLength(65);
for (const { x, y, z } of points) {
const distance = Math.hypot(x, y, z);
expect(distance).toBeGreaterThanOrEqual(5 * (1 - 0.4) - 1e-9);
expect(distance).toBeLessThanOrEqual(5 * (1 + 0.4) + 1e-9);
}
// The first and last sampled points (true anomaly 0 and 2*pi) should coincide.
expect(points[0].x).toBeCloseTo(points[64].x, 9);
expect(points[0].y).toBeCloseTo(points[64].y, 9);
expect(points[0].z).toBeCloseTo(points[64].z, 9);
});
});
describe('resolveOrbitalElements', () => {
it('defaults missing angles to 0 and the missing epoch to J2000', () => {
const resolved = resolveOrbitalElements({ semiMajorAxisAu: 1.5, eccentricity: 0.1 });
expect(resolved.inclinationDeg).toBe(0);
expect(resolved.longitudeOfAscendingNodeDeg).toBe(0);
expect(resolved.argumentOfPeriapsisDeg).toBe(0);
expect(resolved.meanAnomalyAtEpochDeg).toBe(0);
expect(resolved.epochJd).toBe(DEFAULT_EPOCH_JD);
});
it('preserves explicitly provided fields', () => {
const resolved = resolveOrbitalElements({ semiMajorAxisAu: 1.5, eccentricity: 0.1, argumentOfPeriapsisDeg: 50 });
expect(resolved.argumentOfPeriapsisDeg).toBe(50);
});
});