Merge main again: readouts built once, said in the instrument's voice

Main moved while the previous merge was being verified. It brought the
shared bodyReadouts builder — one source for a body's measured and derived
rows, used by the detail page and the system view's new object card — plus
the derived-value asterisk in the HUD readout panel.

All of that data flow is kept. The templates it arrived in are restyled to
this branch's idiom: the info panel and the object card share the same
organism (header, full-bleed readout rows, provenance line, route rail),
the object card's route rail sits at the bottom because there the route is
the next step rather than the way back, and the derived asterisk and its
footnote keep their meaning in sentence case. The card also needed the
restyle to render at all — it arrived wearing hud-panel, a class the
observatory system no longer defines.

Verified: build clean, 527/527 unit, 6/6 e2e, design detector clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-17 16:16:50 +02:00
co-authored by Claude Fable 5
27 changed files with 1175 additions and 234 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. */
@@ -127,44 +125,18 @@ 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);
if (viewModel.hostStarId !== undefined) {
this.navigationStore.selectStar(viewModel.hostStarId);
}
this.notFound.set(false);
this.navigationStore.selectBody(id);
@@ -173,18 +145,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) {
@@ -13,6 +13,12 @@ export interface BodyDetailViewModel {
name: string;
kind: BodyDetailKind;
hostStarName: string;
/**
* The host star's catalogue id, so a caller that has the view model does not have to rescan the
* catalogues to find what building it already resolved. Undefined for an exoplanet whose host
* never cross-referenced to the star catalogue.
*/
hostStarId?: number;
radiusKm?: number;
massEarth?: number;
discoveryYear?: number;
@@ -26,4 +32,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,82 @@
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity';
import { Readout } from '../../shared/models/readout';
import { BodyDetailViewModel } from './body-detail.model';
export const KIND_LABELS: Readonly<Record<BodyDetailViewModel['kind'], string>> = {
planet: 'Planet',
moon: 'Moon',
dwarf: 'Dwarf planet',
exoplanet: 'Exoplanet'
};
export interface BodyReadouts {
readonly kindLabel: string;
/** Only what a catalogue actually published for this body. */
readonly measured: readonly Readout[];
/** Everything computed from those measurements rather than observed directly. */
readonly derived: readonly Readout[];
/** Says plainly which of the two the surface being drawn is. */
readonly provenance: string;
}
/**
* Turns a body into the rows its panels display.
*
* Shared by the in-map card and the detail page for the same reason `buildBodyViewModel` is:
* two independent assemblies of "what do we know about this world" drift, and here the drift
* would be in which side of the measured/derived line a quantity falls on — which is the one
* distinction these panels exist to make.
*/
export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
const measured: Readout[] = [];
if (body.radiusKm !== undefined) {
measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) });
}
if (body.massEarth !== undefined) {
measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) });
}
if (body.orbit.semiMajorAxisAu !== undefined) {
measured.push({ label: 'Semi-major axis', value: formatAu(body.orbit.semiMajorAxisAu) });
}
if (body.orbit.eccentricity !== undefined) {
measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) });
}
if (body.orbit.inclinationDeg !== undefined) {
measured.push({ label: 'Inclination', value: `${body.orbit.inclinationDeg.toFixed(2)}°` });
}
// The period sits under whichever heading its provenance calls for. Same number, same field —
// a published period is an observation and a computed one is not.
if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'measured') {
measured.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) });
}
if (body.discoveryYear !== undefined) {
measured.push({ label: 'Discovered', value: `${body.discoveryYear}` });
}
const derived: Readout[] = [{ label: 'Class', value: PLANET_CLASS_LABELS[body.appearance.planetClass] }];
if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'derived') {
derived.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) });
}
if (body.appearance.equilibriumTemperatureK !== null) {
derived.push({ label: 'Equilibrium temp.', value: formatTemperature(body.appearance.equilibriumTemperatureK) });
}
if (body.appearance.bulkDensityGramsPerCm3 !== null) {
derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) });
}
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) };
}
/**
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next
* to it is exactly the context in which it could be mistaken for another one.
*/
function provenanceFor(body: BodyDetailViewModel): string {
if (body.hasPhotography) {
return 'Surface: NASA/ESA/USGS photography.';
}
return body.appearance.equilibriumTemperatureK === null
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
}
@@ -0,0 +1,124 @@
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('carries the host star id, so callers need not rescan the catalogues for it', () => {
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
});
});
@@ -0,0 +1,99 @@
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);
const periodDays = heliocentricPeriodDays(body);
return {
id: body.id,
name: body.name,
kind: body.kind,
hostStarName: hostStar?.name ?? 'Unknown star',
hostStarId: body.systemStarId,
radiusKm: body.radiusKm,
orbit: body.orbit,
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
hasPhotography: bodyTexturePath(body.id) !== undefined,
orbitalPeriodDays: periodDays,
orbitalPeriodSource: periodDays === 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,
hostStarId: exoplanet.hostStarId ?? undefined,
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,21 +1,16 @@
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 { bodyReadouts } from './body-readouts';
import { BodyDetailViewModel } from './body-detail.model';
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
planet: 'Planet',
moon: 'Moon',
dwarf: 'Dwarf planet',
exoplanet: 'Exoplanet'
};
/**
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
*
* The rows come from `bodyReadouts`, shared with the system view's object card so the same body
* cannot read differently in the two places it can be inspected.
*/
@Component({
selector: 'app-info-panel',
@@ -36,103 +31,44 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
<header class="px-4 pt-4 pb-3">
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header>
<!-- Readout rows: label left, figure right, unit tinted back so the number is what the eye
lands on. Tabular figures keep the decimal points aligned down the column. -->
<dl class="divide-y divide-border/25 border-t border-border/40">
@if (body().radiusKm) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Radius</dt>
<dd class="text-sm tabular-nums">{{ body().radiusKm | number: '1.0-1' }} <span class="text-muted">km</span></dd>
</div>
}
@if (body().massEarth) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Mass</dt>
<dd class="text-sm tabular-nums">{{ body().massEarth | number: '1.0-2' }} <span class="text-muted">Earth masses</span></dd>
</div>
}
@if (body().orbit.semiMajorAxisAu) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Semi-major axis</dt>
<dd class="text-sm tabular-nums">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} <span class="text-muted">AU</span></dd>
</div>
}
@if (body().orbit.eccentricity !== undefined) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Eccentricity</dt>
<dd class="text-sm tabular-nums">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
</div>
}
@if (body().orbit.inclinationDeg !== undefined) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Inclination</dt>
<dd class="text-sm tabular-nums">{{ body().orbit.inclinationDeg | number: '1.0-2' }}<span class="text-muted">°</span></dd>
</div>
}
@if (body().discoveryYear) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Discovered</dt>
<dd class="text-sm tabular-nums">{{ body().discoveryYear }}</dd>
</div>
}
</dl>
<!-- Readout rows: label left, figure right. Tabular figures keep the decimal points
aligned down the column. -->
@if (readouts().measured.length) {
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Measured</p>
<dl class="divide-y divide-border/25">
@for (row of readouts().measured; track row.label) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
</div>
}
</dl>
}
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Derived</p>
<dl class="divide-y divide-border/25">
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Class</dt>
<dd class="text-sm">{{ classLabel() }}</dd>
</div>
@if (body().appearance.equilibriumTemperatureK !== null) {
@for (row of readouts().derived; track row.label) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Equilibrium temp.</dt>
<dd class="text-sm tabular-nums">{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} <span class="text-muted">K</span></dd>
</div>
}
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Bulk density</dt>
<dd class="text-sm tabular-nums">{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} <span class="text-muted">g/cm³</span></dd>
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
</div>
}
</dl>
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
</div>
`,
imports: [DecimalPipe]
`
})
export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>();
readonly readouts = computed(() => bodyReadouts(this.body()));
constructor(private readonly router: Router) {}
kindLabel(): string {
return KIND_LABELS[this.body().kind];
}
classLabel(): string {
return PLANET_CLASS_LABELS[this.body().appearance.planetClass];
}
/**
* Says plainly which of the two the viewer is looking at. The derived surface is a reasoned
* illustration, and a panel of real measurements sitting next to it is exactly the context in
* which it could be mistaken for another one.
*/
surfaceProvenance(): string {
if (this.body().hasPhotography) {
return 'Surface: NASA/ESA/USGS photography.';
}
const temperature = this.body().appearance.equilibriumTemperatureK;
return temperature === null
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
}
goBack(): void {
void this.router.navigate(['/']);
}