Draw every star at its own radius, measured where the archive has one and derived otherwise

The system view drew the Sun at its own radius and every other star at 0.45 of its innermost
orbit, capped at 0.2 AU: a size chosen so the star would not swallow its planets, not the star's.
Proxima Centauri was drawn at 2.8 solar radii, eighteen times its own, and every star without
planets at 43.

starSurfaceOf (body-view-model.ts) now gives each star a radius and a temperature. A planet host
takes the archive's st_rad and st_teff from its planets' rows: 4 439 hosts are drawn at a
measured radius, 22 at a derived one. Every other star's is derived: its temperature off Pecaut & Mamajek's dwarf
sequence at its colour (the same table the spectral estimate reads, or at the colour its type
implies where it has none), its luminosity from its absolute magnitude and the bolometric
correction luminositySolar already applies, and R = sqrt(L) / (T / 5772 K)^2. Against the
archive's own st_rad for the 1 447 catalogue hosts that have one, the derived radius is within
0.018 dex at the median, 0.071 dex at the 90th percentile, and within a factor of 1.5 for
97.1 %. Sirius comes out 1.79 solar radii (1.711 published, Liebert et al. 2005), Wolf 359 0.117,
Betelgeuse 584, the Sun exactly 1.

A star with no band has only the ETL's stand-in magnitude, and gets no derived radius: PSR
J1719-1438 came out 2.3 solar radii from it, wider than its planet's orbit. With the stars that
have neither a colour nor a type, that leaves 3 077 of 455 608 stars (274 of 4 735 hosts) with
no radius; they are drawn at the Sun's, and their card gives none.

The card says which it is: "Radius 0.141 solar radii" for a published one, "~0.10 solar radii,
from colour and brightness" for a derived one, two figures because colour does not give three.

A giant drawn at its size can be wider than its system, so systemFramingDistanceAu also makes
room for the star, and the controls' closest approach is now three of the star's radii where
that is more than the old 0.05 AU. 23 211 stars are drawn wider than 3.6 solar radii, which put
0.05 AU inside three of their radii, and a zoom would have carried the camera through the
surface of the largest. The Sun keeps 0.05 AU. starMarkerRadiusAu and the renderer's innermost
axis, which only it read, are gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:27:39 +02:00
co-authored by Claude Opus 5.5
parent 3392f06c85
commit 7213f987c4
10 changed files with 226 additions and 121 deletions
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
import { buildBodyViewModel, heliocentricPeriodDays } from './body-view-model';
import { buildBodyViewModel, heliocentricPeriodDays, starSurfaceOf } from './body-view-model';
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
semiMajorAxisAu: 1,
@@ -122,3 +122,36 @@ describe('buildBodyViewModel', () => {
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
});
});
describe('starSurfaceOf', () => {
// Proxima Centauri as HYG describes it, and one of its planets' archive rows.
const proxima: StarRecord = { id: 70666, name: 'Proxima Centauri', x: 1.2959, y: 0, z: 0, magnitude: 11.01, magnitudeBand: 'V', spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' };
const proximaB: ExoplanetRecord = { id: 'proxima-cen-b', hostStarId: 70666, hostStarName: 'Proxima Cen', name: 'Proxima Cen b', orbit: { semiMajorAxisAu: 0.0485 } };
it("is the Sun's own for the Sun, and not derived", () => {
expect(starSurfaceOf(sun, [])).toEqual({ radiusSolar: 1, radiusDerived: false, temperatureK: 5772 });
});
it("takes a host's radius and temperature from the archive", () => {
const surface = starSurfaceOf(proxima, [{ ...proximaB, hostStarRadiusSolar: 0.141, hostStarTemperatureK: 2900 }]);
expect(surface).toEqual({ radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900 });
});
it('derives both otherwise, and says the radius is derived', () => {
const surface = starSurfaceOf(proxima, [proximaB]);
expect(surface.radiusDerived).toBe(true);
// Its colour reads as an M5 dwarf: 3 068 K and 0.105 R☉, against 2 900 K and 0.154 R☉
// measured (Kervella et al. 2017). B−V barely changes along the late M dwarfs.
expect(surface.temperatureK).toBeCloseTo(3068, -1);
expect(surface.radiusSolar).toBeCloseTo(0.105, 2);
});
it('has no radius for a star with neither a colour nor a type', () => {
expect(starSurfaceOf({ ...proxima, colorIndex: null, spectralType: 'Unknown' }, []).radiusSolar).toBeNull();
});
it('has none from a magnitude no survey measured', () => {
// No band: the magnitude is the ETL's stand-in, and the luminosity from it means nothing.
expect(starSurfaceOf({ ...proxima, magnitudeBand: undefined }, []).radiusSolar).toBeNull();
});
});
@@ -1,6 +1,6 @@
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
import { luminositySolar } from '../../shared/astro/stellar';
import { effectiveTemperatureK, luminositySolar, radiusFromLuminositySolar, SOLAR_EFFECTIVE_TEMPERATURE_K, StellarPhotometry } from '../../shared/astro/stellar';
import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
@@ -20,17 +20,54 @@ export interface BodyCatalogues {
* off the spectral type where there is no colour.
*/
export function luminosityOf(star: StarRecord | undefined): number | null {
if (!star) {
return null;
return star ? luminositySolar(photometryOf(star)) : null;
}
return luminositySolar({
function photometryOf(star: StarRecord): StellarPhotometry {
return {
magnitude: star.magnitude,
distancePc: Math.hypot(star.x, star.y, star.z),
spectralType: star.spectralType,
magnitudeBand: star.magnitudeBand,
colorIndex: star.colorIndex,
colorSystem: star.colorSystem,
});
};
}
/** How big and how hot a star is, and whether the radius was measured or derived here. */
export interface StarSurface {
/** Solar radii; `null` without a published radius, a measured magnitude, and a colour or type. */
radiusSolar: number | null;
radiusDerived: boolean;
temperatureK: number | null;
}
/**
* A star's radius and effective temperature: the archive's `st_rad` and `st_teff` for a planet
* host, from any of its planets' rows, and otherwise derived — the temperature off the dwarf
* sequence at the star's colour, the radius from that and the luminosity (Stefan-Boltzmann).
* The Sun's are its own, the nominal values the rest are measured in.
*
* Derived radii land within a factor of 1.5 of the archive's for 97 % of the 1 447 catalogue
* hosts that have both, and within 0.018 dex at the median.
*/
export function starSurfaceOf(star: StarRecord, planets: readonly ExoplanetRecord[]): StarSurface {
if (star.id === SUN_STAR_ID) {
return { radiusSolar: 1, radiusDerived: false, temperatureK: SOLAR_EFFECTIVE_TEMPERATURE_K };
}
const temperatureK = planets.find((planet) => planet.hostStarTemperatureK)?.hostStarTemperatureK ?? effectiveTemperatureK(photometryOf(star));
const measured = planets.find((planet) => planet.hostStarRadiusSolar)?.hostStarRadiusSolar;
if (measured) {
return { radiusSolar: measured, radiusDerived: false, temperatureK };
}
// Not from the ETL's stand-in magnitude, which is all 309 stars without a band have: PSR
// J1719-1438 came out 2.3 solar radii, wider than its planet's orbit.
const luminosity = star.magnitudeBand ? luminosityOf(star) : null;
return {
radiusSolar: luminosity !== null && temperatureK !== null ? radiusFromLuminositySolar(luminosity, temperatureK) : null,
radiusDerived: true,
temperatureK,
};
}
/**
@@ -40,7 +40,7 @@ import { DeepSkyRenderer } from './deep-sky-renderer';
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
import { MilkyWayRenderer } from './milky-way-renderer';
import {
starMarkerRadiusAu,
closestApproachAu,
SUN_RADIUS_AU,
systemFrameRadiusAu,
systemFramingDistanceAu,
@@ -55,7 +55,7 @@ import {
type ScaleBar,
} from '../../shared/format/scale-bar';
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
import { buildBodyViewModel, luminosityOf, starSurfaceOf, StarSurface } from '../body-detail/body-view-model';
import {
DEFAULT_HUD_DISPLAY,
HudDisplay,
@@ -284,7 +284,6 @@ const GALACTIC_LEVEL_THRESHOLD = 0.5;
const SYSTEM_NEAR_AU = 0.002;
const SYSTEM_FAR_AU = 20000;
const SYSTEM_MIN_DISTANCE_AU = 0.05;
const SYSTEM_MAX_DISTANCE_AU = 5000;
/** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */
const SYSTEM_ENTRY_DISTANCE_AU = 200;
@@ -415,7 +414,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly galaxyGroup = new THREE.Group();
private readonly systemGroup = new THREE.Group();
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
/** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */
/** Rebuilt per system, since every star has its own radius. */
private starMarkerGeometry?: THREE.SphereGeometry;
/** Readout panel contents, refreshed on the same cadence as the labels rather than per frame. */
@@ -551,6 +550,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private currentStarId: number | null = null;
private systemRenderer?: SystemOrbitsRenderer;
private starMarker?: THREE.Mesh;
/** The system's star's radius and temperature, worked out once on entering it. */
private currentStarSurface?: StarSurface;
constructor(
private readonly engine: EngineService,
@@ -1754,7 +1755,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
label: 'Bodies',
value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}`,
},
...starReadouts(star, luminosityOf(star)),
...starReadouts(star, luminosityOf(star), this.currentStarSurface),
]);
this.hudNote.set(this.time.atNow() ? 'Orbits propagated from published elements to the current date.' : 'Orbits propagated from published elements to the date on the clock.');
this.hudRange.set(
@@ -2203,10 +2204,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.systemGroup.add(this.systemRenderer.object);
this.applyDisplay(this.display());
// Every star at its own radius: the archive's for a planet host, otherwise derived from its
// colour and brightness — or the Sun's, for the 3 077 stars with no measured magnitude or with
// neither a colour nor a type, which the card then gives no radius.
this.currentStarSurface = starSurfaceOf(star, systemExoplanets);
const starRadiusAu = (this.currentStarSurface.radiusSolar ?? 1) * SUN_RADIUS_AU;
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
// the wider of the two — and against the camera this scene actually has, so the margin holds
// whatever the window shape. Computed before the star, because how far away the star will be
// seen from is what decides how big its halo has to be to stay visible.
// the wider of the two — or against the star, for a giant wider than both; and against the
// camera this scene actually has, so the margin holds whatever the window shape.
// Framed against the perspective camera whichever is active: the framing distance is what
// the orthographic frustum is then sized from, so both projections show the same extent.
const framingCamera = this.engine.getPerspectiveCamera();
@@ -2214,14 +2220,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const framingDistance = systemFramingDistanceAu(
this.systemRenderer.gridOuterRadiusAu,
viewport,
starRadiusAu,
);
// The Sun at its own radius; every other star sized against its innermost orbit, which is all
// the catalogue supports, and which at least never lets it swallow its own planets.
const starRadiusAu =
star.id === SOL_STAR_ID
? SUN_RADIUS_AU
: starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
this.starMarkerGeometry?.dispose();
this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 64, 32);
@@ -2255,7 +2255,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
depthCamera.near = SYSTEM_NEAR_AU;
depthCamera.far = SYSTEM_FAR_AU;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.minDistance = closestApproachAu(starRadiusAu);
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
this.resetZoom();
@@ -67,6 +67,18 @@ describe('starReadouts', () => {
expect(value(starReadouts({ ...HYG_STAR, source: 'exoplanet-archive' }, null), 'Source')).toBe('NASA Exoplanet Archive');
});
it('says a radius was derived, and from what; a published one plainly', () => {
expect(starReadouts(PLACED_BY_GAIA, null, { radiusSolar: 0.1049, radiusDerived: true, temperatureK: 3068 }).find((readout) => readout.label === 'Radius')).toEqual({
label: 'Radius',
value: '~0.10 solar radii, from colour and brightness',
derived: true
});
expect(value(starReadouts(PLACED_BY_GAIA, null, { radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900 }), 'Radius')).toBe('0.141 solar radii');
expect(value(starReadouts(HYG_STAR, null, { radiusSolar: 584.3, radiusDerived: true, temperatureK: 3590 }), 'Radius')).toBe('~580 solar radii, from colour and brightness');
expect(value(starReadouts(HYG_STAR, null, { radiusSolar: 1, radiusDerived: false, temperatureK: 5772 }), 'Radius')).toBe('1.00 solar radii');
expect(starReadouts(HYG_STAR, null, { radiusSolar: null, radiusDerived: true, temperatureK: null }).some((readout) => readout.label === 'Radius')).toBe(false);
});
it('marks the luminosity as derived', () => {
expect(starReadouts(HYG_STAR, 25.4).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '25.40 L☉', derived: true });
});
@@ -1,6 +1,7 @@
import { spectralTypeFromColor } from '../../shared/astro/spectral';
import { formatDistance, formatLuminosity } from '../../shared/format/quantity';
import { StarRecord } from '../../shared/models/star.model';
import { StarSurface } from '../body-detail/body-view-model';
import { HudReadout } from '../hud/hud-dock.component';
/**
@@ -33,9 +34,9 @@ export function starSubtitle(star: StarRecord): string {
/**
* A star's measured readouts, each with what it was measured in: the band of its magnitude, which
* colour its colour index is, the distance's uncertainty, and the catalogues they come from.
* `luminosity` is derived, and marked so.
* `luminosity` is derived, and marked so; so is a radius derived from it, and it says from what.
*/
export function starReadouts(star: StarRecord, luminosity: number | null): HudReadout[] {
export function starReadouts(star: StarRecord, luminosity: number | null, surface?: StarSurface): HudReadout[] {
const distancePc = Math.hypot(star.x, star.y, star.z);
const catalogue = describingCatalogue(star);
return [
@@ -48,10 +49,20 @@ export function starReadouts(star: StarRecord, luminosity: number | null): HudRe
? [{ label: 'Colour', value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}` }]
: []),
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []),
...(surface?.radiusSolar ? [radiusReadout(surface.radiusSolar, surface.radiusDerived)] : []),
{ label: 'Source', value: catalogue === 'HYG' && star.distanceFromGaia ? 'HYG, Gaia DR3 distance' : catalogue }
];
}
/** Two figures for a derived radius, three for a published one: 0.105 is not what colour gives. */
function radiusReadout(radiusSolar: number, derived: boolean): HudReadout {
const digits = derived ? 2 : 3;
const figure = radiusSolar.toLocaleString('en-GB', { minimumSignificantDigits: digits, maximumSignificantDigits: digits });
return derived
? { label: 'Radius', value: `~${figure} solar radii, from colour and brightness`, derived: true }
: { label: 'Radius', value: `${figure} solar radii` };
}
/** What the catalogue holds, counted by the catalogue describing each star, largest first. */
export function catalogueCensus(stars: readonly StarRecord[]): string {
const counts = new Map<string, number>();
@@ -4,8 +4,7 @@ import { describe, expect, it } from 'vitest';
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
import {
bodyMarkerRadiusAu,
DEFAULT_STAR_MARKER_RADIUS_AU,
starMarkerRadiusAu,
closestApproachAu,
systemFrameRadiusAu,
systemFramingDistanceAu,
systemGridRingsAu,
@@ -19,39 +18,6 @@ const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
const GL_357 = { innermost: 0.035, outermost: 0.204 };
const SOLAR = { innermost: 0.387, outermost: 30.07 };
describe('starMarkerRadiusAu', () => {
it('never reaches the innermost orbit', () => {
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
}
});
it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => {
// Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system.
expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost);
expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost);
});
it('never grows beyond the default, however wide the system', () => {
expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU);
expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
});
it('scales in proportion to the innermost orbit', () => {
expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9);
});
it('falls back to the default when there are no planets to scale against', () => {
for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
}
});
it('stays positive for an extremely tight orbit', () => {
expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0);
});
});
describe('systemFramingDistanceAu', () => {
it('fits the radius it is given in view, with room around it', () => {
for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) {
@@ -88,6 +54,15 @@ describe('systemFramingDistanceAu', () => {
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
});
it('backs off to hold a giant wider than its system, and leaves a dwarf to the system', () => {
// Betelgeuse drawn at 584 solar radii, 2.7 AU, with nothing around it; the Sun inside its own.
const betelgeuseAu = 2.72;
expect(systemFrameRadiusAu(systemFramingDistanceAu(0, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu);
expect(systemFrameRadiusAu(systemFramingDistanceAu(0.5, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu);
expect(systemFramingDistanceAu(SOLAR.outermost, undefined, 0.00465)).toBe(systemFramingDistanceAu(SOLAR.outermost));
expect(systemFramingDistanceAu(0, undefined, 0.00465)).toBe(systemFramingDistanceAu(0));
});
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
});
@@ -154,25 +129,10 @@ describe('the grid and the framing together', () => {
});
});
describe('star and framing together', () => {
it('gives compact and wide systems a comparable apparent star size', () => {
// Both scale with the system, so the star subtends a similar angle either way — the point
// of deriving them from the same measurements rather than fixing them.
const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) =>
starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost);
const compact = apparent(TRAPPIST_1);
const midRange = apparent(GL_357);
expect(compact).toBeGreaterThan(0);
expect(compact / midRange).toBeGreaterThan(0.25);
expect(compact / midRange).toBeLessThan(4);
});
it('always leaves the innermost orbit outside the star, at every scale', () => {
for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) {
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
}
describe('closestApproachAu', () => {
it('keeps the camera three radii out from a giant, and at the old floor for the Sun', () => {
expect(closestApproachAu(0.00465)).toBe(0.05);
expect(closestApproachAu(2.72)).toBeCloseTo(8.16, 9);
});
});
@@ -12,21 +12,11 @@ import { CartesianCoordinates } from '../../shared/astro/coordinates';
* the star marker, so they rendered as a lone sphere with nothing around it, and 52% were
* framed from a distance floor far larger than the system itself.
*
* Both quantities are therefore derived from the system's own scale. Because the star and the
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
* same apparent spread of orbits.
* The camera's distance is therefore derived from the system's own scale. The star is not: it is
* drawn at its own radius, like every body here, and the framing only makes room for it when the
* star is a giant wider than its system.
*/
/** Star size when there are no orbits to scale against, and the ceiling everywhere else. */
export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
/**
* Star radius as a fraction of the innermost orbit. Comfortably below 1 so there is visible
* space between the star's limb and the closest orbit, rather than the orbit grazing or
* disappearing inside it.
*/
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
/**
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
@@ -103,20 +93,6 @@ export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vec
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
}
/**
* Radius (AU) to draw the system's star at, given its innermost orbit.
*
* Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the
* closest orbit. Falls back to that default when the system has no planets, since there is
* then nothing for the star to crowd.
*/
export function starMarkerRadiusAu(innermostOrbitAu: number): number {
if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) {
return DEFAULT_STAR_MARKER_RADIUS_AU;
}
return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT);
}
/**
* Radius, in AU, that the camera can see at the star's own distance — the half-height of the
* view frustum where the system sits, along whichever screen axis is tighter.
@@ -138,14 +114,32 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring
* rather than the outermost orbit — the ring is always the wider of the two, by construction.
*/
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT, starRadiusAu = 0): number {
// A giant drawn at its own radius can be wider than the system around it — Betelgeuse's 584
// solar radii are 2.7 AU — or than the empty framing, and the camera must not settle inside it.
const star = (starRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
return Math.max(EMPTY_SYSTEM_FRAMING_DISTANCE_AU, star);
}
const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
const required = Math.max((framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport), star);
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
}
/** How close the camera may come to the star's centre, whatever the star: ten solar radii. */
const MIN_APPROACH_AU = 0.05;
/** From three radii out a star spans 39 degrees, most of the view's 50, and the camera stays out of it. */
const STAR_CLEARANCE_RADII = 3;
/**
* The orbit controls' minimum distance in a system. The fixed 0.05 AU it used to be leaves the
* Sun 11 degrees across; but 23 211 stars on the map are drawn wider than 3.6 solar radii, which
* puts 0.05 AU inside three of their radii, and a giant's surface further out still — a zoom
* would have carried the camera through it.
*/
export function closestApproachAu(starRadiusAu: number): number {
return Math.max(MIN_APPROACH_AU, STAR_CLEARANCE_RADII * starRadiusAu);
}
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
const TARGET_GRID_RING_COUNT = 8;
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
@@ -192,13 +186,8 @@ const KM_PER_AU = 149597870.7;
const DEFAULT_BODY_RADIUS_KM = 6371;
/**
* The Sun's own radius, in AU — the one star whose size this map knows.
*
* Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost
* orbit rather than measured, because no stellar radius reaches the app: the catalogue carries
* positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn
* here, and until the ETL fetches it, a system's star is the one body in the view that is not
* to scale.
* The Sun's own radius, in AU: the unit every star's radius is drawn in, the archive's or the one
* `starSurfaceOf` derives from its colour and brightness.
*/
export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
@@ -264,8 +264,6 @@ export class SystemOrbitsRenderer {
readonly members: readonly SystemMember[];
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly maxTopLevelSemiMajorAxisAu: number;
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly minTopLevelSemiMajorAxisAu: number;
/**
* The plane this system is read against, as a rotation from XY into the scene's equatorial
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
@@ -308,7 +306,6 @@ export class SystemOrbitsRenderer {
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
].filter((axis) => Number.isFinite(axis) && axis > 0);
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
for (const body of bodies) {
if (!body.parentBodyId) {
+43 -1
View File
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest';
import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar';
import {
absoluteMagnitude,
bolometricCorrection,
effectiveTemperatureK,
luminositySolar,
radiusFromLuminositySolar,
SOLAR_ABSOLUTE_MAGNITUDE_V,
SOLAR_BOLOMETRIC_MAGNITUDE,
SOLAR_EFFECTIVE_TEMPERATURE_K
} from './stellar';
/** Real catalogue rows, with the published luminosity each one should reproduce. */
const SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 };
@@ -122,3 +131,36 @@ describe('luminositySolar', () => {
expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull();
});
});
describe('effectiveTemperatureK', () => {
it("is the Sun's own for the Sun", () => {
expect(effectiveTemperatureK({ magnitude: -26.7, distancePc: 0, colorIndex: 0.7 })).toBe(SOLAR_EFFECTIVE_TEMPERATURE_K);
});
it('reads a colour in its own system, and a spectral type where there is no colour', () => {
// An M5 dwarf is 3 060 K at B−V 1.83 or BP−RP 3.35. Its type alone goes through the colour
// `spectralTypeToColorIndex` gives it, B−V 1.70, and comes out a little warmer.
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 3.35, colorSystem: 'BP-RP' })).toBeCloseTo(3060, 0);
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 1.83, colorSystem: 'B-V' })).toBeCloseTo(3060, 0);
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'M5Ve', colorIndex: null })).toBeCloseTo(3106, 0);
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'Unknown', colorIndex: null })).toBeNull();
});
});
describe('radiusFromLuminositySolar', () => {
it('is one for the Sun', () => {
expect(radiusFromLuminositySolar(1, SOLAR_EFFECTIVE_TEMPERATURE_K)).toBeCloseTo(1, 12);
});
it('gives Sirius and TRAPPIST-1 their published radii from colour and brightness alone', () => {
// 1.711 R☉ (Liebert et al. 2005) and 0.119 R☉ (Agol et al. 2021), each to within a fifth.
for (const [star, published] of [
[{ magnitude: -1.44, distancePc: 2.6371, magnitudeBand: 'V', colorIndex: 0.009, colorSystem: 'B-V' }, 1.711],
[{ magnitude: 15.6226, distancePc: 12.467, magnitudeBand: 'G', colorIndex: 4.902, colorSystem: 'BP-RP' }, 0.119]
] as const) {
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
expect(radius / published).toBeGreaterThan(0.8);
expect(radius / published).toBeLessThan(1.2);
}
});
});
+25 -1
View File
@@ -1,4 +1,4 @@
import { dwarfSequenceAtColor, parseSpectralClass, SpectralClass } from './spectral';
import { dwarfSequenceAtColor, parseSpectralClass, SpectralClass, spectralTypeToColorIndex } from './spectral';
/**
* Stellar luminosity, derived from the two things the star catalogue actually measures.
@@ -135,3 +135,27 @@ export function luminositySolar(star: StellarPhotometry): number | null {
return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR);
}
/** The Sun's effective temperature, the IAU 2015 nominal value. */
export const SOLAR_EFFECTIVE_TEMPERATURE_K = 5772;
/**
* Effective temperature, off the dwarf sequence at the star's colour, or at the colour its
* spectral type implies where it has none. Exactly the Sun's for the Sun, which is at zero
* distance here. A giant is read as the dwarf of its colour: a few hundred kelvin too cool at K.
*/
export function effectiveTemperatureK(star: StellarPhotometry): number | null {
if (star.distancePc === 0) {
return SOLAR_EFFECTIVE_TEMPERATURE_K;
}
const measured = star.colorIndex != null ? dwarfSequenceAtColor(star.colorIndex, star.colorSystem) : null;
return (measured ?? dwarfSequenceAtColor(spectralTypeToColorIndex(star.spectralType)))?.temperatureK ?? null;
}
/**
* Radius in solar radii from luminosity and temperature — Stefan-Boltzmann, L = 4πR²σT⁴, in solar
* units. Luminosity-class blind, since the luminosity comes from the distance: a giant comes out a
* giant whatever the sequence took it for.
*/
export function radiusFromLuminositySolar(luminositySolar: number, temperatureK: number): number {
return Math.sqrt(luminositySolar) / (temperatureK / SOLAR_EFFECTIVE_TEMPERATURE_K) ** 2;
}