Draw the 1509 exoplanets that were being silently dropped

The system renderer required both a semi-major axis and an eccentricity before
it would place an exoplanet, even though resolveOrbitalElements already defaults
every other missing element. The archive publishes an axis far more often than
an eccentricity: 3895 records have one and only 2386 have both, so 1509 planets
were dropped for want of a value that can simply be assumed.

A missing eccentricity now defaults to 0, a circle. That is the conventional
assumption for an orbit whose shape has not been constrained, and it is the only
honest option available, since the axis alone says nothing about elongation.

The effect is not subtle. 18 systems gain planets, and seven of them previously
rendered as a bare star with nothing around it at all: Gl 357 goes from zero
planets to three, HD 176986 likewise. Beyond the effect today, a user could
already reach one of these planets through search and its detail page, then jump
to its system and find it missing from the very system it belongs to.

isPropagatableOrbit replaces the old inline guard and also rejects what the old
one never checked: a non-positive axis, and an eccentricity of 1 or more. Those
are escape trajectories that no ellipse describes, and propagating them anyway
does not throw — it yields NaN, which reaches the vertex buffer and poisons the
geometry's bounding sphere, disabling culling for the whole object rather than
just the bad orbit. Being a type guard, it also lets the caller drop a seven-line
field-by-field copy of the orbit.

Fixes a label leak found while verifying this in the browser. Galaxy star labels
were being cleared on entering system space but immediately recomputed, because
the tick gated them on `currentStarId`, which is not assigned until the arrival
flight finishes a second later — so parsec-scale names sat pinned over the
system. Both label and orbit updates now gate on which group is actually visible,
which is true throughout the transition rather than only at the end of it.

Tests: 185 passing, up from 171. Verified in a real browser: GJ 1151 draws the
orbit and marker it gained, and no labels survive into the system view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-04 11:31:59 +00:00
parent 8d8c65bdb2
commit f241b093eb
5 changed files with 157 additions and 20 deletions
+56
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
import {
gravitationalParameterFromPeriod,
isPropagatableOrbit,
meanMotionRadPerDay,
orbitEllipsePoints,
orbitalPeriodDays,
@@ -252,3 +253,58 @@ describe('resolveGravitationalParameter', () => {
expect(gm / GM_SUN_AU3_PER_DAY2).toBeCloseTo(1.1, 1);
});
});
describe('isPropagatableOrbit', () => {
it('accepts an orbit with only a semi-major axis', () => {
// 1509 archive records publish an axis and no eccentricity; they are perfectly drawable.
expect(isPropagatableOrbit({ semiMajorAxisAu: 1 })).toBe(true);
});
it('accepts a fully specified elliptical orbit', () => {
expect(isPropagatableOrbit({ semiMajorAxisAu: 0.05, eccentricity: 0.62 })).toBe(true);
});
it('accepts the boundary eccentricities of an ellipse', () => {
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity: 0 })).toBe(true);
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity: 0.999 })).toBe(true);
});
it('rejects an orbit with no semi-major axis at all', () => {
expect(isPropagatableOrbit({})).toBe(false);
expect(isPropagatableOrbit({ eccentricity: 0.1 })).toBe(false);
});
it('rejects a non-positive or non-finite semi-major axis', () => {
// sqrt of a negative and division by zero both yield NaN rather than throwing.
for (const semiMajorAxisAu of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(isPropagatableOrbit({ semiMajorAxisAu })).toBe(false);
}
});
it('rejects an eccentricity that is not an ellipse', () => {
// e >= 1 is a parabolic or hyperbolic escape trajectory, which no ellipse describes.
for (const eccentricity of [1, 1.4, -0.2, Number.NaN]) {
expect(isPropagatableOrbit({ semiMajorAxisAu: 1, eccentricity })).toBe(false);
}
});
});
describe('resolveOrbitalElements eccentricity default', () => {
it('treats a missing eccentricity as a circle', () => {
expect(resolveOrbitalElements({ semiMajorAxisAu: 2 }).eccentricity).toBe(0);
});
it('keeps a published eccentricity, including exactly zero', () => {
expect(resolveOrbitalElements({ semiMajorAxisAu: 2, eccentricity: 0.35 }).eccentricity).toBe(0.35);
expect(resolveOrbitalElements({ semiMajorAxisAu: 2, eccentricity: 0 }).eccentricity).toBe(0);
});
it('produces a genuine circle, not a degenerate ellipse', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 2 });
const radii = orbitEllipsePoints(elements).map((point) => Math.hypot(point.x, point.y, point.z));
for (const radius of radii) {
expect(radius).toBeCloseTo(2, 9);
}
});
});
+39 -6
View File
@@ -7,15 +7,21 @@ const TWO_PI = Math.PI * 2;
/**
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: inclination, longitude
* of ascending node, mean anomaly at epoch, and the epoch itself. Missing angles default to
* zero (a face-on, unrotated ellipse) and the missing epoch defaults to J2000 — enough to draw
* a plausible, period-correct orbit even without full data.
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: eccentricity,
* inclination, longitude of ascending node, mean anomaly at epoch, and the epoch itself.
* Missing angles default to zero (a face-on, unrotated ellipse) and the missing epoch defaults
* to J2000 — enough to draw a plausible, period-correct orbit even without full data.
*
* A missing eccentricity defaults to 0, a circle. That is the conventional assumption for an
* orbit whose shape has not been constrained, and it is also the only honest one available: the
* semi-major axis alone says nothing about elongation. It matters because the archive publishes
* an axis far more often than an eccentricity — 1509 exoplanets have the first without the
* second — and treating those as undrawable simply hid them.
*/
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu' | 'eccentricity'>): OrbitalElements {
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'>): OrbitalElements {
return {
semiMajorAxisAu: partial.semiMajorAxisAu,
eccentricity: partial.eccentricity,
eccentricity: partial.eccentricity ?? 0,
inclinationDeg: partial.inclinationDeg ?? 0,
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
@@ -24,6 +30,33 @@ export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<
};
}
/**
* Whether a partially-specified orbit can actually be propagated as an ellipse.
*
* Everything downstream — the mean motion, the Kepler solver, the ellipse sampling — assumes a
* closed elliptical orbit around a positive semi-major axis. Feed it anything else and it does
* not throw: `sqrt` of a negative number and division by zero both yield `NaN`, which
* propagates silently into the vertex buffer and poisons the geometry's bounding sphere, taking
* out culling for the whole object rather than just the bad orbit.
*
* A missing eccentricity is fine and defaults to a circle (see {@link resolveOrbitalElements});
* a present but non-elliptical one (`e >= 1`, an escape trajectory) is not, since no ellipse
* describes it.
*/
export function isPropagatableOrbit(
partial: Partial<OrbitalElements>
): partial is Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu'> {
const { semiMajorAxisAu, eccentricity } = partial;
if (semiMajorAxisAu === undefined || !Number.isFinite(semiMajorAxisAu) || semiMajorAxisAu <= 0) {
return false;
}
if (eccentricity !== undefined && (!Number.isFinite(eccentricity) || eccentricity < 0 || eccentricity >= 1)) {
return false;
}
return true;
}
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));