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
@@ -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(['/']);
}