Files
star-map/src/app/features/galaxy-system/starmap-hud.component.ts
T
SenrokaiandClaude Opus 5 43b9b1f081 Give the map a scale bar, and rings that say how far from the Sun
The map had one way to read a distance: the Range readout, a number for how
far back the camera is. The local grid's five rings sat at 50 to 250 pc,
fixed and unlabelled. They said nothing from inside a 2 pc hop, and nothing
past 250 pc now that the Hipparcos stars Gaia places there are drawn.

A scale bar now sits under the scale rail. It shows the longest round length
(1, 2 or 5 x 10^n) that fits in 120 px, in AU inside a system and in parsecs
or kiloparsecs outside. It is measured at the depth the view is centred on,
since under perspective every depth has its own scale; under the plan view
it is exact everywhere.

The local grid's rings are now distances from the Sun, at a round step of
about a fifth of the camera's distance and out past the camera: 50 to 350 pc
from the opening view, 2 to 20 pc from twenty parsecs out. Each ring is
labelled with its distance, on the side facing what the view is centred on,
or across the far side of the grid when that is the Sun (the near side is
under the camera and out of frame). The survey edge at 250 pc stays called
out, as "Survey edge", whatever the step.

The rounding lives in one place, scale-bar.ts, shared by the bar and the
rings and tested there. Its formatter keeps three significant digits: one
digit, enough for the bar's round lengths, printed the 250 pc ring as
"300 pc".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-11 19:26:39 +02:00

115 lines
5.7 KiB
TypeScript

import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ScaleBar } from '../../shared/format/scale-bar';
import { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
interface LadderStep {
readonly level: ViewLevel;
readonly label: string;
/** Above the active step, on it, or below it — which is what the step is styled from. */
readonly state: 'above' | 'current' | 'below';
/** Whether this step is somewhere the view can be sent right now. */
readonly reachable: boolean;
}
/** Outermost first — the order the ladder is drawn in, and the order levels nest. */
const LADDER: readonly { level: ViewLevel; label: string }[] = [
{ level: 'galactic', label: 'Milky Way' },
{ level: 'galaxy', label: 'Solar Neighbourhood' },
{ level: 'system', label: 'System' }
];
/**
* The top of the map's heads-up display: the scale ladder on the left with the scale bar under
* it, 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.
*
* Reachable levels render as buttons and the current one renders as a static marker rather than
* a button that does nothing. The galactic and neighbourhood scales are always reachable, in
* both directions: they are one continuous space and the Sun is always in it. The system scale
* is not, since there is no system to go to until a star has been picked.
*/
@Component({
selector: 'app-starmap-hud',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReticleIconComponent],
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
template: `
@if (showReticle()) {
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
for the whole instrument, whether it is holding a query or a body. -->
<app-reticle-icon class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" [strokeWidth]="1" />
}
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
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
type="button"
(click)="levelSelected.emit(step.level)"
class="type-eyebrow px-4 py-1.5 text-muted 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"
>
{{ step.label }}
</button>
} @else {
<span
[attr.aria-current]="step.state === 'current' ? 'step' : null"
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
class="type-eyebrow px-4 py-1.5"
[class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
>{{ step.label }}</span
>
}
}
</nav>
@if (scale(); as bar) {
<!-- The map's scale bar, under the rail that names the scale: a round length, measured at the
depth the view is centred on, since under perspective every depth has its own. -->
<div data-testid="hud-scale" role="img" [attr.aria-label]="'Scale: ' + bar.label" class="absolute top-16 left-6 flex flex-col items-start gap-1">
<span class="type-label text-muted tabular-nums">{{ bar.label }}</span>
<span class="block h-1.5 border-x border-b border-accent/70" [style.width.px]="bar.widthPx"></span>
</div>
}
@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-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>
}
`
})
export class StarmapHudComponent {
readonly level = input.required<ViewLevel>();
/** What the view is holding, for the nameplate — the selected star, or nothing. */
readonly title = input('');
readonly showReticle = input(true);
/** The scale bar for the current zoom, worked out by the scene, which knows the camera. */
readonly scale = input<ScaleBar | null>(null);
readonly levelSelected = output<ViewLevel>();
readonly ladder = computed<readonly LadderStep[]>(() => {
const activeIndex = LADDER.findIndex((step) => step.level === this.level());
return LADDER.map((step, index) => ({
...step,
state: index < activeIndex ? 'above' : index === activeIndex ? 'current' : 'below',
// Every scale is somewhere the camera can be sent except the current one and the system
// level, which needs a star picked first — there is no "the system" without a selection.
reachable: index !== activeIndex && step.level !== 'system'
}));
});
}