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
@@ -5,7 +5,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { dateToJulianDate } from '../../shared/astro/constants';
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
import { luminositySolar } from '../../shared/astro/stellar';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
@@ -20,7 +19,11 @@ import { DeepSkyRenderer } from './deep-sky-renderer';
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
import { MilkyWayRenderer } from './milky-way-renderer';
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
@@ -125,15 +128,6 @@ const EXIT_DURATION_SECONDS = 0.9;
const RETURN_DURATION_SECONDS = 1.1;
const GALACTIC_FLIGHT_SECONDS = 2.4;
/** Camera range for the readout panel, in the unit that suits the distance. */
function formatParsecs(distancePc: number): string {
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
}
function formatAu(distanceAu: number): string {
return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`;
}
/**
* Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking
* at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the
@@ -157,7 +151,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
@Component({
selector: 'app-galaxy-system-scene',
providers: [EngineService],
imports: [StarmapHudComponent],
imports: [StarmapHudComponent, SystemObjectCardComponent],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@@ -172,6 +166,9 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
[range]="hudRange()"
(levelSelected)="goToLevel($event)"
/>
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
</div>
`
})
@@ -194,6 +191,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
readonly hudNote = signal('');
readonly hudRange = signal('');
/**
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
* over. Undefined outside the system view, and cleared when the view leaves one.
*/
readonly objectCard = signal<BodyDetailViewModel | undefined>(undefined);
private enterableSystems = 0;
private pinnedBodyId: string | null = null;
private hoveredBodyId: string | null = null;
private controls?: OrbitControls;
private rig?: CameraRigController;
private starField?: StarFieldRenderer;
@@ -251,6 +257,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.resizeObserver?.disconnect();
this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown);
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
this.controls?.dispose();
this.starField?.dispose();
this.deepSky?.dispose();
@@ -341,6 +348,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.bodies = bodies;
this.exoplanets = exoplanets;
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
this.enterableSystems = new Set<number>([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter((id) => id !== null)).size;
// Built once rather than per label refresh: it is a scan of every body and exoplanet, and the
// labels are recomputed whenever the camera moves.
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
@@ -389,6 +398,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
canvas.addEventListener('pointerdown', this.handlePointerDown);
canvas.addEventListener('click', this.handleClick);
canvas.addEventListener('pointermove', this.handlePointerMove);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
@@ -628,13 +638,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.systemGroup.visible && star) {
const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length;
const moonCount = this.bodies.filter((body) => body.systemStarId === star.id && body.parentBodyId).length;
const distancePc = Math.hypot(star.x, star.y, star.z);
const luminosity = luminosityOf(star);
this.hudEyebrow.set('System');
this.hudTitle.set(star.name);
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
this.hudReadouts.set([
{ label: 'Bodies', value: `${planetCount}` },
{ label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` },
{ label: 'Magnitude', value: star.magnitude.toFixed(2) }
{ label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}` },
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
// and reads as a bug: the distance from here to here is not a measurement.
...(distancePc > 0 ? [{ label: 'Distance', value: formatParsecs(distancePc) }] : []),
{ label: 'Magnitude', value: star.magnitude.toFixed(2) },
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
]);
this.hudNote.set('Orbits propagated from published elements to the current date.');
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
@@ -665,7 +681,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// what it draws. See `STAR_RENDER_BUDGET`.
{ label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` },
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
{ label: 'Exoplanets', value: `${this.exoplanets.length}` }
{ label: 'Exoplanets', value: `${this.exoplanets.length}` },
// The one thing the field itself cannot show: which of those points can be flown into.
{ label: 'Systems', value: `${this.enterableSystems}` }
]);
this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
}
@@ -714,16 +732,76 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
}
/**
* Picks a body in the system view. Clicking one pins its card; clicking empty space unpins,
* which is also how the card is dismissed without aiming for its close control.
*
* This 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 — the card
* shows the same numbers over the live view instead, and `Full view` still opens the route.
*/
private handleSystemClick(): void {
if (!this.systemRenderer) {
return;
}
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
this.pinnedBodyId = member ? member.id : null;
if (member) {
this.navigationStore.selectBody(member.id);
void this.router.navigate(['/body', member.id]);
}
this.refreshObjectCard();
}
/**
* Hover preview, so a body's figures can be read without committing a click.
*
* The raycast is against the system's own handful of pickable meshes rather than the star field,
* so it stays cheap even on a software rasterizer — it is the rendering that is slow in that
* environment, not the picking. Skipped outside the system view and during a camera flight.
*/
private readonly handlePointerMove = (event: PointerEvent): void => {
if (!this.systemRenderer || !this.systemGroup.visible || this.rig?.isAnimating) {
return;
}
const canvas = this.canvasRef().nativeElement;
const rect = canvas.getBoundingClientRect();
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointerNdc, this.engine.getCamera());
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const hoveredId = (hit ? this.systemRenderer.memberForObject(hit.object) : undefined)?.id ?? null;
if (hoveredId === this.hoveredBodyId) {
return;
}
this.hoveredBodyId = hoveredId;
canvas.style.cursor = hoveredId ? 'pointer' : '';
this.refreshObjectCard();
};
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */
private refreshObjectCard(): void {
const id = this.pinnedBodyId ?? this.hoveredBodyId;
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
}
/** Clears the card and everything that would bring it straight back. */
private clearObjectCard(): void {
this.pinnedBodyId = null;
this.hoveredBodyId = null;
this.objectCard.set(undefined);
this.canvasRef().nativeElement.style.cursor = '';
}
dismissObjectCard(): void {
this.clearObjectCard();
}
/** The deliberate step out to the dedicated route, from the card's own control. */
openObjectDetail(id: string): void {
this.navigationStore.selectBody(id);
void this.router.navigate(['/body', id]);
}
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
@@ -797,7 +875,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// exoplanets, so it has no meaningful direction and the renderer falls back.
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
// decides how hot each body in the system is — and so what each of them looks like.
const hostLuminosity = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
const hostLuminosity = luminosityOf(star);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
this.systemGroup.add(this.systemRenderer.object);
@@ -881,6 +959,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.systemGroup.visible = false;
this.galaxyGroup.visible = true;
// The bodies it described are no longer on screen, and a stale pin would otherwise survive
// into the next system entered.
this.clearObjectCard();
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
@@ -5,6 +5,12 @@ import { ViewLevel } from '../../shared/state/navigation.store';
export interface HudReadout {
readonly label: string;
readonly value: string;
/**
* True when the figure was computed from other measurements rather than catalogued directly.
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
* an observed one.
*/
readonly derived?: boolean;
}
interface LadderStep {
@@ -101,14 +107,14 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) {
<div>
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ readout.label }}</dt>
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
</div>
}
</dl>
}
@if (note()) {
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">{{ note() }}</p>
@if (note() || hasDerived()) {
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
}
</div>
@@ -128,6 +134,9 @@ export class StarmapHudComponent {
readonly readouts = input<readonly HudReadout[]>([]);
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
readonly note = input('');
/** Whether any readout needs the derived-value footnote. */
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
readonly range = input('');
readonly showReticle = input(true);
@@ -0,0 +1,116 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
import { SystemObjectCardComponent } from './system-object-card.component';
const appearance = (overrides: Partial<PlanetAppearance> = {}): PlanetAppearance =>
({
planetClass: 'temperate',
palette: { structure: 'mottled' },
equilibriumTemperatureK: 255,
bulkDensityGramsPerCm3: 5.51,
polarCapExtentDeg: 25,
seed: 1,
...overrides,
}) as PlanetAppearance;
const earth: BodyDetailViewModel = {
id: 'earth',
name: 'Earth',
kind: 'planet',
hostStarName: 'Sol',
radiusKm: 6371,
orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167 },
appearance: appearance(),
hasPhotography: true,
orbitalPeriodDays: 365.25,
orbitalPeriodSource: 'derived',
};
describe('SystemObjectCardComponent', () => {
let fixture: ComponentFixture<SystemObjectCardComponent>;
function render(body: BodyDetailViewModel): HTMLElement {
fixture.componentRef.setInput('body', body);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
/** The label/value pairs under one of the two headings, in order. */
function block(host: HTMLElement, heading: 'Measured' | 'Derived'): string[] {
const headings = [...host.querySelectorAll('p')].filter((p) => p.textContent?.trim() === heading);
const list = headings[0]?.nextElementSibling;
return list ? [...list.querySelectorAll('dt')].map((dt) => dt.textContent?.trim() ?? '') : [];
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SystemObjectCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(SystemObjectCardComponent);
});
it('files a period computed from the semi-major axis under Derived', () => {
const host = render(earth);
expect(block(host, 'Derived')).toContain('Period');
expect(block(host, 'Measured')).not.toContain('Period');
});
it('files a published period under Measured instead', () => {
// Same field, opposite heading — the provenance flag is what decides, not the field's name.
const host = render({ ...earth, orbitalPeriodSource: 'measured' });
expect(block(host, 'Measured')).toContain('Period');
expect(block(host, 'Derived')).not.toContain('Period');
});
it('omits the period entirely when there is none to show', () => {
const host = render({ ...earth, orbitalPeriodDays: undefined, orbitalPeriodSource: undefined });
expect(block(host, 'Measured')).not.toContain('Period');
expect(block(host, 'Derived')).not.toContain('Period');
});
it('never shows an empty Measured block', () => {
const bare: BodyDetailViewModel = {
...earth,
radiusKm: undefined,
orbit: {},
orbitalPeriodDays: undefined,
orbitalPeriodSource: undefined,
discoveryYear: undefined,
};
expect(render(bare).textContent).not.toContain('Measured');
});
it('says a photographed surface is a photograph', () => {
expect(render(earth).textContent).toContain('photography');
});
it('says an illustrated surface is not an observation', () => {
expect(render({ ...earth, hasPhotography: false }).textContent).toContain('Not an observation');
});
it('explains a missing temperature rather than leaving the row blank', () => {
const host = render({
...earth,
hasPhotography: false,
appearance: appearance({ equilibriumTemperatureK: null }),
});
expect(block(host, 'Derived')).not.toContain('Equilibrium temp.');
expect(host.textContent).toContain('host star is not in the catalogue');
});
it('emits rather than navigating, so the scene decides what selection means', () => {
const host = render(earth);
let opened = 0;
let dismissed = 0;
fixture.componentInstance.openRequested.subscribe(() => (opened += 1));
fixture.componentInstance.dismissed.subscribe(() => (dismissed += 1));
host.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.click();
[...host.querySelectorAll('button')].find((button) => button.textContent?.includes('Full view'))!.click();
expect({ opened, dismissed }).toEqual({ opened: 1, dismissed: 1 });
});
});
@@ -0,0 +1,93 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { bodyReadouts } from '../body-detail/body-readouts';
/**
* The card shown for a body picked in the system view, without leaving it.
*
* Selecting a planet used to navigate straight to `/body/:id`, which tore down the system scene
* and the camera position with it — so comparing two planets meant flying back in twice. This
* shows the same numbers over the live view, and keeps the route as the deliberate step for the
* full 3D inspection.
*
* The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only.
*/
@Component({
selector: 'app-system-object-card',
changeDetection: ChangeDetectionStrategy.OnPush,
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
// the scene rather than against whatever box the inline default would have left it in — which
// put the card off the bottom-left corner of the viewport entirely.
host: { class: 'pointer-events-none absolute inset-0 block' },
template: `
<!-- Positioning and panel styling stay on separate elements, so the panel's own layout
never fights the absolute placement. Same organism as the detail page's info panel:
header, full-bleed readout rows, provenance, and a route rail — there at the top,
here at the bottom, because here the route is the next step rather than the way back. -->
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]">
<div data-testid="object-card" class="hud-brackets hud-acquire border border-border/60 bg-panel/92 font-body text-text backdrop-blur-md">
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<header class="min-w-0">
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header>
<button
type="button"
(click)="dismissed.emit()"
aria-label="Close"
class="-mt-2 -mr-2 shrink-0 p-1 text-muted transition-colors hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-accent"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
</button>
</div>
@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">
@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">{{ 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">{{ readouts().provenance }}</p>
<button
type="button"
(click)="openRequested.emit()"
class="flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 py-2 text-[10px] tracking-[0.16em] text-muted uppercase transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
Full view
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6l6 6-6 6" />
</svg>
</button>
</div>
</div>
`
})
export class SystemObjectCardComponent {
readonly body = input.required<BodyDetailViewModel>();
/** The close control, and anything else that should dismiss the card. */
readonly dismissed = output<void>();
/** Request for the full `/body/:id` route. */
readonly openRequested = output<void>();
readonly readouts = computed(() => bodyReadouts(this.body()));
}