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) {