Assemble a body's readouts once, not once per panel

Follow-up to review on #3. The card and the detail page each built their own Measured/Derived
split, kind label and provenance sentence — the drift buildBodyViewModel exists to prevent,
re-forked one layer up, and the drift would have been in which side of the measured/derived line
a quantity falls on, which is the distinction those panels exist to draw. One bodyReadouts(body)
now returns both blocks and the sentence, and both templates iterate it. The two surfaces render
identical rows as a result, and the card gains the inclination the detail page already showed.

Also from that review:

- KIND_LABELS was duplicated between the two panels; it now lives beside bodyReadouts. The third
  copy the review pointed at is a different union (search results are star/body/exoplanet, and
  label a body "Body"), so it stays where it is.
- CardRow was HudReadout renamed. Both are now Readout, which HudReadout extends with its
  derived flag.
- The enterable-systems count was a 21-line lazy memo over arrays that are already in hand; it is
  one expression where those arrays are assigned.
- buildBodyViewModel now carries hostStarId, so the detail scene stops rescanning both catalogues
  for something the builder had already resolved.
- heliocentricPeriodDays was called twice for the same body.
- The superscript helper was a split/map/join; it is a replace.
- info-panel had five computed() each wrapping one pure call with a non-null assertion, beside a
  template that inlined the same kind of call directly. They are gone with the shared readouts.
- Dropped a tautological test that compared a pure function to itself. Replaced with one that
  asserts the host star id the builder now carries.
- Removed the orphaned doc comment left behind when formatParsecs/formatAu moved out.

And one the review raised as out of scope but is worth taking: formatRadiusKm grouped thousands
above its decimal threshold and not below it, so 69,911 km sat beside a bare 6371 km. Both are
grouped now.

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-06 03:52:11 +00:00
parent 1019727a39
commit 7b0f32f71a
12 changed files with 146 additions and 230 deletions
@@ -1,21 +1,16 @@
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 { 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',
@@ -33,104 +28,37 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
</button>
<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="mb-4 text-xs tracking-wide text-accent uppercase">{{ readouts().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 tabular-nums">{{ radius() }}</dd>
}
@if (body().massEarth) {
<dt class="text-muted">Mass</dt>
<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 tabular-nums">{{ semiMajorAxis() }}</dd>
}
@if (body().orbit.eccentricity !== undefined) {
<dt class="text-muted">Eccentricity</dt>
<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 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 tabular-nums">{{ body().discoveryYear }}</dd>
}
</dl>
@if (readouts().measured.length) {
<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">
@for (row of readouts().measured; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
}
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
<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 tabular-nums">{{ temperature() }}</dd>
}
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
<dt class="text-muted">Bulk density</dt>
<dd class="text-right text-text tabular-nums">{{ density() }}</dd>
@for (row of readouts().derived; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
</div>
`
})
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;
}
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(['/']);
}