Dock the HUD: every tool and readout on one rail along the bottom

The overlay had grown by accretion: a search box floating top-centre, a
readout panel bottom-left, a range readout bottom-right, and nothing that
said these were parts of one instrument. This puts them on one rail across
the bottom of the viewport — the dock — with a tab strip pinned to the bottom
edge and whichever panel is open growing upward from it. The top of the
screen keeps only the scale ladder and the nameplate, so the map itself is
what fills the frame.

Three tabs. SEARCH is the old search, with its field pinned to the bottom of
the panel and the results growing upward above it, so the thing being typed
into never moves while the list grows. READOUT is the old bottom-left panel.
DISPLAY is new: five layer toggles — labels, orbits, grid, deep sky, sky —
each a real scene object switched by visibility, except the ones the galaxy
crossfade already rewrites every frame, whose toggles fold into that
crossfade instead of fighting it. The range readout sits on the strip itself,
so it is readable whatever is open.

Behaviour worth stating: choosing a search result hands the panel straight
back to the readout, since the thing to look at is now the scene. `/` opens
the search from anywhere. Below `sm` the dock is the strip alone; a tap opens
a panel as a sheet, a tap on the scene folds it away. The body-detail page
gets the same dock with only the search — the info panel is its reading.

Two things found on the way. CSS2DRenderer gives every label its own
z-index for depth order, and the label host created no stacking context, so
labels painted over every HUD panel; `isolate` on the host keeps them under.
And starmap-hud's readout tests were really tests of the panel that moved,
so they moved with it.

Verified: build clean, 535/535 unit, 6/6 end-to-end, design detector clean,
screenshots at 1440×900 and 390×844 across galaxy, galactic, system,
body-detail, all three tabs and the layers-off state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-18 20:57:10 +02:00
co-authored by Claude Fable 5
parent 9ae87e83fb
commit 4a1cc5240a
15 changed files with 505 additions and 117 deletions
@@ -22,7 +22,8 @@ import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramin
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 { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '../hud/hud-dock.component';
import { 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';
@@ -151,24 +152,28 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
@Component({
selector: 'app-galaxy-system-scene',
providers: [EngineService],
imports: [StarmapHudComponent, SystemObjectCardComponent],
imports: [HudDockComponent, StarmapHudComponent, SystemObjectCardComponent],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
<app-starmap-hud
[level]="navigationStore.viewLevel()"
<!-- isolate: CSS2DRenderer gives every label its own z-index for depth ordering; without a
stacking context here those indices escape and the labels paint over the HUD. -->
<div #labelHost class="pointer-events-none absolute inset-0 isolate overflow-hidden"></div>
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" (levelSelected)="goToLevel($event)" />
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
<app-hud-dock
[eyebrow]="hudEyebrow()"
[title]="hudTitle()"
[subtitle]="hudSubtitle()"
[readouts]="hudReadouts()"
[note]="hudNote()"
[range]="hudRange()"
(levelSelected)="goToLevel($event)"
[display]="display()"
defaultTab="readout"
(displayChange)="display.set($event)"
/>
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
</div>
`
})
@@ -190,6 +195,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
readonly hudReadouts = signal<readonly HudReadout[]>([]);
readonly hudNote = signal('');
readonly hudRange = signal('');
/** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */
readonly display = signal<HudDisplay>(DEFAULT_HUD_DISPLAY);
/**
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
@@ -246,6 +253,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.reconcileSelection(selectedStarId);
}
});
effect(() => this.applyDisplay(this.display()));
}
ngAfterViewInit(): void {
@@ -393,6 +401,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.labelOverlay = new StarLabelOverlay(scene);
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
this.applyDisplay(this.display());
const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height);
@@ -453,14 +462,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const distancePc = camera.position.length();
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
this.galacticGrid?.setStrength(this.galacticStrength);
this.localGrid?.setStrength(1 - this.galacticStrength);
this.tethers?.setStrength(1 - this.galacticStrength);
// Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
// `setStrength` rewrites visibility every frame from the strength it is given, so a hidden
// layer has to be told a strength of zero every frame too.
const display = this.display();
this.galacticGrid?.setStrength(display.grid ? this.galacticStrength : 0);
this.localGrid?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
this.tethers?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
this.deepSky?.setStrength(1 - this.galacticStrength);
this.deepSky?.setStrength(display.deepSky ? 1 - this.galacticStrength : 0);
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
this.engine.getScene().backgroundIntensity = 1 - this.galacticStrength;
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
this.applyGalaxyDepthRange(camera, distancePc);
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
@@ -633,6 +646,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
/** Refreshes the readout panel for whichever scale the view is currently at. */
/**
* Shows or hides the layers that hold still between frames: the label layer and the system
* view's orbits and grid. The galaxy grids, deep-sky shell and skybox are crossfaded every
* frame instead, so their toggles live in `updateGalacticCrossfade`.
*/
private applyDisplay(display: HudDisplay): void {
if (this.labelOverlay) {
this.labelOverlay.domElement.style.display = display.labels ? '' : 'none';
}
this.systemRenderer?.setLayerVisibility({ orbits: display.orbits, grid: display.grid });
}
private updateHud(camera: THREE.PerspectiveCamera): void {
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
@@ -878,6 +903,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
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);
this.applyDisplay(this.display());
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
// the wider of the two — and against the camera this scene actually has, so the margin holds
@@ -67,28 +67,6 @@ describe('StarmapHudComponent', () => {
expect(emitted).toEqual(['galactic', 'galaxy']);
});
it('renders the readout panel from its inputs', () => {
fixture.componentRef.setInput('level', 'galactic');
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
fixture.componentRef.setInput('title', 'Milky Way');
fixture.componentRef.setInput('subtitle', 'Barred spiral');
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
fixture.detectChanges();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
expect(text).toContain(expected);
}
});
it('leaves out the optional lines it was given nothing for', () => {
const host = render('galaxy');
expect(host.querySelector('dl')).toBeNull();
expect(host.textContent).not.toContain('undefined');
});
it('names what the view is holding on the banner across the top', () => {
fixture.componentRef.setInput('level', 'system');
fixture.componentRef.setInput('title', 'Sol');
@@ -3,17 +3,6 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
import { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
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 {
readonly level: ViewLevel;
readonly label: string;
@@ -31,9 +20,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
];
/**
* The map's heads-up display: the scale ladder down the left, the readout panel across the
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around
* the whole viewport.
* The top of the map's heads-up display: the scale ladder on the left, the nameplate across
* the centre, and a centre reticle on whatever the camera is holding. Readouts and tools live
* in the dock along the bottom (`HudDockComponent`).
*
* Purely presentational — every value arrives as an input and the only thing it emits is a
* request to move to another scale. The scene owns the camera and decides what that means.
@@ -58,8 +47,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
}
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
across the top of the display, clear of the search field above them. -->
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40">
across the top of the display; the search lives in the dock below, so nothing sits above. -->
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-6 left-6 flex items-stretch divide-x divide-border/40">
@for (step of ladder(); track step.level) {
@if (step.reachable) {
<button
@@ -84,58 +73,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@if (title()) {
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
long star name runs into the scale rail on its left and under the object card on its
right — all three share the top-16 line. -->
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block">
right — all three share the top-6 line. -->
<div class="absolute top-6 left-1/2 hidden -translate-x-1/2 lg:block">
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
</div>
</div>
}
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
<div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
<p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
}
@if (readouts().length) {
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) {
<div>
<dt class="type-label text-muted">{{ 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() || 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>
<div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
<p class="type-label text-muted">Range</p>
<p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
</div>
</div>
`
})
export class StarmapHudComponent {
readonly level = input.required<ViewLevel>();
/** Headline for the readout panel — the selected star, or the name of the current scale. */
/** What the view is holding, for the nameplate — the selected star, or nothing. */
readonly title = input('');
readonly subtitle = input('');
readonly eyebrow = input('');
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);
readonly levelSelected = output<ViewLevel>();
@@ -29,7 +29,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
never fights the absolute placement. Same organism as the detail page's info panel:
header, shared readout sections, 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 class="pointer-events-auto absolute top-6 right-6 w-80 max-w-[calc(100%-3rem)]">
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<header class="min-w-0">
@@ -88,6 +88,9 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
}
}
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
const ORBIT_LINE_NAME = 'orbit-line';
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3);
@@ -110,7 +113,9 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
});
return new THREE.Line(geometry, material);
const line = new THREE.Line(geometry, material);
line.name = ORBIT_LINE_NAME;
return line;
}
/**
@@ -329,6 +334,21 @@ export class SystemOrbitsRenderer {
return this.members.map((member) => member.marker);
}
/** Shows or hides the orbit lines and the reference grid, leaving the bodies themselves. */
setLayerVisibility(layers: { orbits: boolean; grid: boolean }): void {
this.object.traverse((child) => {
if (child.name === ORBIT_LINE_NAME) {
child.visible = layers.orbits;
}
});
if (this.grid) {
this.grid.object.visible = layers.grid;
}
if (this.tethers) {
this.tethers.object.visible = layers.grid;
}
}
dispose(): void {
this.grid?.dispose();
this.tethers?.dispose();