Say what is known about a world, and how it is known

Four changes to the readout panel and the body cards, which between them were showing less than
the catalogues hold and not always distinguishing a measurement from an inference.

Picking a planet used to navigate straight to /body/:id. That tore down the system scene and the
camera with it, so comparing two planets meant flying back into the system between each. Hovering
a body now raises a card over the live view and clicking pins it; Full view still opens the route
for the full 3D inspection. Clicking empty space unpins, and leaving the system clears it.

The card and the detail page were assembling "what do we know about this world" independently,
which is the shape of bug where a planet reads 255 K in one panel and 254 K in the other. Both now
build from one shared view model.

Orbital period was absent everywhere. For a heliocentric orbit it follows exactly from the
semi-major axis, because in these units the Sun's mass is the unit of mass — Mars comes back
687.0 d against a published 686.98. It is deliberately not computed for moons, whose elements are
relative to a parent planet the catalogue has no mass for, nor for exoplanets: periodDays is
populated for none of the 6319 shipped records and hostStarMassSolar for none either, so any
figure would assume a solar-mass host and mis-state every planet around an M dwarf. Where a period
does exist it is filed under Measured or Derived according to which it is, not by its field name.

The system readout showed a flat 0.00 pc for the Sun's distance, which is arithmetically right and
reads as a bug — the distance from here to here is not a measurement, so it is suppressed. It
gains the host star's luminosity, marked as derived, and counts moons separately from planets. The
neighbourhood readout gains the one thing the star field cannot show: how many of those points can
actually be entered. Derived readouts carry a marker and a footnote saying so.

Every quantity now formats through one module whose precision follows magnitude, rather than a
fixed decimal count per call site that read as false precision at one end and lost real
information at the other: 0.0026 AU stays legible instead of rounding to 0.00, and Pluto's period
reads 248 yr rather than 90560 d.

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-05 16:45:17 +00:00
parent 60b3726a84
commit 1019727a39
11 changed files with 856 additions and 78 deletions
@@ -6,9 +6,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
import { luminositySolar } from '../../shared/astro/stellar';
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
@@ -17,6 +14,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component';
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
@@ -118,44 +116,24 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
return;
}
const body = this.bodies.find((candidate) => candidate.id === id);
const exoplanet = this.exoplanets.find((candidate) => candidate.id === id);
if (body) {
const hostStar = this.stars.find((star) => star.id === body.systemStarId);
this.viewModel.set({
id: body.id,
name: body.name,
kind: body.kind,
hostStarName: hostStar?.name ?? 'Unknown star',
radiusKm: body.radiusKm,
orbit: body.orbit,
appearance: appearanceForBody(body, this.bodies, this.luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(body.id) !== undefined
});
this.navigationStore.selectStar(body.systemStarId);
} else if (exoplanet) {
const hostStar = this.stars.find((star) => star.id === exoplanet.hostStarId);
this.viewModel.set({
id: exoplanet.id,
name: exoplanet.name,
kind: 'exoplanet',
hostStarName: exoplanet.hostStarName,
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
massEarth: exoplanet.massEarth,
discoveryYear: exoplanet.discoveryYear,
orbit: exoplanet.orbit,
appearance: appearanceForExoplanet(exoplanet, this.luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined
});
if (exoplanet.hostStarId !== null) {
this.navigationStore.selectStar(exoplanet.hostStarId);
}
} else {
// Shared with the system view's object card, so the same body cannot read differently there.
const viewModel = buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars });
if (!viewModel) {
this.viewModel.set(undefined);
this.notFound.set(true);
return;
}
this.viewModel.set(viewModel);
const body = this.bodies.find((candidate) => candidate.id === id);
if (body) {
this.navigationStore.selectStar(body.systemStarId);
} else {
const exoplanet = this.exoplanets.find((candidate) => candidate.id === id);
if (exoplanet?.hostStarId != null) {
this.navigationStore.selectStar(exoplanet.hostStarId);
}
}
this.notFound.set(false);
this.navigationStore.selectBody(id);
@@ -164,18 +142,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
}
}
/**
* The host star's luminosity in solar units, from its own catalogued magnitude and distance.
* `null` for an exoplanet whose host never cross-referenced to the star catalogue, which
* leaves its planets with no derived temperature rather than a guessed one.
*/
private luminosityOf(star: StarRecord | undefined): number | null {
if (!star) {
return null;
}
return luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
}
private applyViewModelToScene(): void {
const viewModel = this.viewModel();
if (!viewModel || !this.planetMaterial) {
@@ -26,4 +26,16 @@ export interface BodyDetailViewModel {
appearance: PlanetAppearance;
/** True when a real photograph is being shown rather than the derived surface. */
hasPhotography: boolean;
/**
* Sidereal orbital period. Measured where the archive published one; otherwise derived from the
* semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined
* when neither applies — see `heliocentricPeriodDays`.
*/
orbitalPeriodDays?: number;
/**
* Which of those two the period is, so the surfaces can file it under the right heading. A
* derived period presented as an observation is the same category of error as presenting a
* derived surface as a photograph.
*/
orbitalPeriodSource?: 'measured' | 'derived';
}
@@ -0,0 +1,125 @@
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';
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
semiMajorAxisAu: 1,
eccentricity: 0.0167,
inclinationDeg: 0,
longitudeOfAscendingNodeDeg: 0,
argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0,
epochJd: 2451545,
...overrides,
});
const sun: StarRecord = {
id: SUN_STAR_ID,
name: 'Sol',
x: 0,
y: 0,
z: 0,
magnitude: -26.7,
spectralType: 'G2V',
colorIndex: 0.65,
};
const earth: BodyRecord = {
id: 'earth',
systemStarId: SUN_STAR_ID,
name: 'Earth',
kind: 'planet',
radiusKm: 6371,
orbit: orbit(),
};
const luna: BodyRecord = {
id: 'luna',
systemStarId: SUN_STAR_ID,
name: 'Moon',
kind: 'moon',
radiusKm: 1737,
parentBodyId: 'earth',
orbit: orbit({ semiMajorAxisAu: 0.00257 }),
};
describe('heliocentricPeriodDays', () => {
it('recovers a known period from the semi-major axis alone', () => {
// P² = a³ in these units, so Earth must come back a year.
expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1);
});
it('scales as the three-halves power', () => {
const jupiter: BodyRecord = {
...earth,
id: 'jupiter',
name: 'Jupiter',
orbit: orbit({ semiMajorAxisAu: 5.2044 }),
};
// Jupiter's real sidereal period is 4332.6 days.
expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1);
});
it('refuses to compute a period for a moon', () => {
// A moon's elements are relative to its planet, whose mass is not in the catalogue — the
// same arithmetic would be wrong by the ratio of that planet's mass to the Sun's.
expect(heliocentricPeriodDays(luna)).toBeUndefined();
});
});
describe('buildBodyViewModel', () => {
const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] };
it('marks a period computed from the semi-major axis as derived', () => {
const model = buildBodyViewModel('earth', catalogues);
expect(model?.orbitalPeriodSource).toBe('derived');
expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1);
});
it('leaves a moon without a period rather than inventing one', () => {
const model = buildBodyViewModel('luna', catalogues);
expect(model?.orbitalPeriodDays).toBeUndefined();
expect(model?.orbitalPeriodSource).toBeUndefined();
});
it('marks a published exoplanet period as measured, not derived', () => {
const exoplanet: ExoplanetRecord = {
id: 'kepler-22-b',
hostStarId: null,
hostStarName: 'Kepler-22',
name: 'Kepler-22 b',
periodDays: 289.9,
orbit: { semiMajorAxisAu: 0.849 },
};
const model = buildBodyViewModel('kepler-22-b', {
bodies: [],
exoplanets: [exoplanet],
stars: [],
});
expect(model?.orbitalPeriodSource).toBe('measured');
expect(model?.orbitalPeriodDays).toBe(289.9);
});
it('leaves an exoplanet with no published period undefined rather than assuming a solar-mass host', () => {
const exoplanet: ExoplanetRecord = {
id: 'x',
hostStarId: null,
hostStarName: 'X',
name: 'X b',
orbit: { semiMajorAxisAu: 0.05 },
};
const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [] });
expect(model?.orbitalPeriodDays).toBeUndefined();
});
it('returns undefined for an id in neither catalogue', () => {
expect(buildBodyViewModel('nowhere', catalogues)).toBeUndefined();
});
it('reads the same body identically however it is reached', () => {
// The whole point of the shared builder: the card and the detail route must not drift.
expect(buildBodyViewModel('earth', catalogues)).toEqual(buildBodyViewModel('earth', catalogues));
});
});
@@ -0,0 +1,96 @@
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
import { luminositySolar } 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';
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
import { BodyDetailViewModel } from './body-detail.model';
/** Everything the view model is assembled from — the three catalogues, already loaded. */
export interface BodyCatalogues {
readonly bodies: readonly BodyRecord[];
readonly exoplanets: readonly ExoplanetRecord[];
readonly stars: readonly StarRecord[];
}
/**
* Bolometric luminosity of a star in solar units, from what the catalogue measured: apparent
* magnitude, parallax distance, and a bolometric correction read off the spectral type.
*/
export function luminosityOf(star: StarRecord | undefined): number | null {
if (!star) {
return null;
}
return luminositySolar({
magnitude: star.magnitude,
distancePc: Math.hypot(star.x, star.y, star.z),
spectralType: star.spectralType,
});
}
/**
* Builds the flattened view model for one body or exoplanet.
*
* Shared rather than duplicated per surface: the in-map card and the full detail page show
* overlapping subsets of the same quantities, and two independent assemblies of "what do we
* know about this world" is exactly the shape of bug where a planet reads 255 K in one panel
* and 254 K in the other.
*/
export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): BodyDetailViewModel | undefined {
const body = catalogues.bodies.find((candidate) => candidate.id === id);
if (body) {
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
return {
id: body.id,
name: body.name,
kind: body.kind,
hostStarName: hostStar?.name ?? 'Unknown star',
radiusKm: body.radiusKm,
orbit: body.orbit,
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(body.id) !== undefined,
orbitalPeriodDays: heliocentricPeriodDays(body),
orbitalPeriodSource: heliocentricPeriodDays(body) === undefined ? undefined : 'derived',
};
}
const exoplanet = catalogues.exoplanets.find((candidate) => candidate.id === id);
if (!exoplanet) {
return undefined;
}
const hostStar = catalogues.stars.find((star) => star.id === exoplanet.hostStarId);
return {
id: exoplanet.id,
name: exoplanet.name,
kind: 'exoplanet',
hostStarName: exoplanet.hostStarName,
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
massEarth: exoplanet.massEarth,
discoveryYear: exoplanet.discoveryYear,
orbit: exoplanet.orbit,
appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
// `periodDays` is populated for none of the shipped records, and deriving one would need the
// host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass
// host, which would silently mis-state the period of every planet around an M dwarf.
orbitalPeriodDays: exoplanet.periodDays,
orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured',
};
}
/**
* Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which
* holds exactly in these units because the Sun's mass is the unit of mass.
*
* Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass
* the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the
* planet's mass to the Sun's — a factor of a thousand for Jupiter.
*/
export function heliocentricPeriodDays(body: BodyRecord): number | undefined {
if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) {
return undefined;
}
const a = body.orbit.semiMajorAxisAu;
return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined;
}
@@ -1,8 +1,8 @@
import { DecimalPipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { Component, computed, input } from '@angular/core';
import { Router } from '@angular/router';
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity';
import { BodyDetailViewModel } from './body-detail.model';
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
@@ -35,30 +35,35 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@if (body().radiusKm) {
<dt class="text-muted">Radius</dt>
<dd class="text-right text-text">{{ body().radiusKm | number: '1.0-1' }} km</dd>
<dd class="text-right text-text tabular-nums">{{ radius() }}</dd>
}
@if (body().massEarth) {
<dt class="text-muted">Mass</dt>
<dd class="text-right text-text">{{ body().massEarth | number: '1.0-2' }} Earth masses</dd>
<dd class="text-right text-text tabular-nums">{{ mass() }}</dd>
}
@if (body().orbit.semiMajorAxisAu) {
<dt class="text-muted">Semi-major axis</dt>
<dd class="text-right text-text">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU</dd>
<dd class="text-right text-text tabular-nums">{{ semiMajorAxis() }}</dd>
}
@if (body().orbit.eccentricity !== undefined) {
<dt class="text-muted">Eccentricity</dt>
<dd class="text-right text-text">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
<dd class="text-right text-text tabular-nums">{{ body().orbit.eccentricity!.toFixed(4) }}</dd>
}
@if (body().orbit.inclinationDeg !== undefined) {
<dt class="text-muted">Inclination</dt>
<dd class="text-right text-text">{{ body().orbit.inclinationDeg | number: '1.0-2' }}°</dd>
<dd class="text-right text-text tabular-nums">{{ body().orbit.inclinationDeg!.toFixed(2) }}°</dd>
}
@if (measuredPeriod()) {
<dt class="text-muted">Period</dt>
<dd class="text-right text-text tabular-nums">{{ measuredPeriod() }}</dd>
}
@if (body().discoveryYear) {
<dt class="text-muted">Discovered</dt>
<dd class="text-right text-text">{{ body().discoveryYear }}</dd>
<dd class="text-right text-text tabular-nums">{{ body().discoveryYear }}</dd>
}
</dl>
@@ -66,24 +71,41 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
<dt class="text-muted">Class</dt>
<dd class="text-right text-text">{{ classLabel() }}</dd>
@if (derivedPeriod()) {
<dt class="text-muted">Period</dt>
<dd class="text-right text-text tabular-nums">{{ derivedPeriod() }}</dd>
}
@if (body().appearance.equilibriumTemperatureK !== null) {
<dt class="text-muted">Equilibrium temp.</dt>
<dd class="text-right text-text">{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} K</dd>
<dd class="text-right text-text tabular-nums">{{ temperature() }}</dd>
}
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
<dt class="text-muted">Bulk density</dt>
<dd class="text-right text-text">{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} g/cm³</dd>
<dd class="text-right text-text tabular-nums">{{ density() }}</dd>
}
</dl>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
</div>
`,
imports: [DecimalPipe]
`
})
export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>();
readonly radius = computed(() => formatRadiusKm(this.body().radiusKm!));
readonly mass = computed(() => formatMassEarth(this.body().massEarth!));
readonly semiMajorAxis = computed(() => formatAu(this.body().orbit.semiMajorAxisAu!));
readonly temperature = computed(() => formatTemperature(this.body().appearance.equilibriumTemperatureK!));
readonly density = computed(() => formatDensity(this.body().appearance.bulkDensityGramsPerCm3!));
/** The period goes under whichever heading its provenance calls for, and nowhere otherwise. */
readonly measuredPeriod = computed(() => this.periodUnder('measured'));
readonly derivedPeriod = computed(() => this.periodUnder('derived'));
private periodUnder(source: 'measured' | 'derived'): string | null {
const body = this.body();
return body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === source ? formatPeriod(body.orbitalPeriodDays) : null;
}
constructor(private readonly router: Router) {}
kindLabel(): string {