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