Merge pull request #5 from avalon-vanguard/feat/hud-dock

Dock the HUD: every tool and readout on one rail along the bottom
This commit is contained in:
Senrokai
2026-08-27 19:19:19 +02:00
committed by GitHub
15 changed files with 517 additions and 117 deletions
+2 -1
View File
@@ -10,7 +10,8 @@ test.describe('Galaxy view', () => {
await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible();
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
await expect(backButtonLocator(page)).toHaveCount(0);
// The readout panel's own title, not just the text anywhere on screen: the selected-object
// banner across the top names the same thing, so a bare text match is ambiguous.
+8 -5
View File
@@ -1,25 +1,28 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
import { backButtonLocator } from './support/wait-for-back-button';
test.describe('Search-driven navigation', () => {
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
const searchInput = await openSearch(page);
await searchInput.fill('Proxima Centauri');
const result = page.getByRole('button', { name: /Proxima Centauri/ });
await expect(result).toBeVisible();
await result.click();
// Selecting a result clears the search query immediately (before the flight even starts).
await expect(searchInput).toHaveValue('');
// Selecting a result hands the dock straight back to the readout (before the flight even
// starts): the thing to look at is now the scene, and the search panel folds away with it.
await expect(page.getByRole('tab', { name: 'Readout' })).toHaveAttribute('aria-selected', 'true');
await expect(searchInput).toHaveCount(0);
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
});
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
const searchInput = await openSearch(page);
await searchInput.fill('Earth');
const result = page.getByText('Earth', { exact: true });
@@ -36,7 +39,7 @@ test.describe('Search-driven navigation', () => {
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
const searchInput = await openSearch(page);
await searchInput.fill('E');
await expect(page.getByTestId('search-results')).toHaveCount(0);
+14
View File
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';
/**
* The search lives in the dock along the bottom, behind its own tab, so a test that wants to
* type has to open it first — the way a user does, or with the `/` shortcut. Returns the field.
*/
export async function openSearch(page: Page): Promise<Locator> {
// Clicking the active tab folds it closed, so only click when it is not already open.
const tab = page.getByRole('tab', { name: 'Search' });
if ((await tab.getAttribute('aria-selected')) !== 'true') {
await tab.click();
}
return page.getByPlaceholder('Search stars, planets, exoplanets…');
}
-1
View File
@@ -4,5 +4,4 @@
aria-hidden="true"
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
></div>
<app-search></app-search>
<router-outlet></router-outlet>
+1 -3
View File
@@ -1,11 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SearchComponent } from './features/search/search.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, SearchComponent],
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss'
})
@@ -14,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 { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { HudDockComponent } from '../hud/hud-dock.component';
import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component';
@@ -37,14 +38,14 @@ const GLOW_SCALE = 2.6;
@Component({
selector: 'app-body-detail-scene',
providers: [EngineService],
imports: [ChevronIconComponent, InfoPanelComponent, RouterLink],
imports: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) {
<app-info-panel [body]="viewModel()!" />
} @else if (notFound()) {
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text xl:top-4">
<div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text">
<p class="type-eyebrow text-accent">No record</p>
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
<a
@@ -56,6 +57,8 @@ const GLOW_SCALE = 2.6;
</a>
</div>
}
<!-- Search only: there is no scene readout here, the info panel is the reading. -->
<app-hud-dock />
</div>
`
})
@@ -19,10 +19,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent],
template: `
<!-- Sits below the search field until xl, beside it from there. The search field is 26rem
wide and centred, so this right-anchored 20rem panel only clears it once the viewport
passes ~1088px — at sm they still overlap and the search would cover the back button. -->
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text xl:top-4">
<!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. -->
<div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
<button
type="button"
(click)="goBack()"
@@ -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,24 @@ 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 and deep-sky shell are crossfaded every frame
* instead, so their toggles live in `updateGalacticCrossfade`. The skybox is both: the
* crossfade rewrites its intensity while the galaxy is up, but the crossfade is parked in
* system view, where the sky is still on screen — so it is also set here, once, on toggle.
*/
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 });
// The effect that calls this fires once at construction, before the engine has a scene.
if (this.engine.isInitialized) {
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
}
}
private updateHud(camera: THREE.PerspectiveCamera): void {
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
@@ -878,6 +909,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();
@@ -0,0 +1,159 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
class EmptyDataLoaderService {
loadStars() {
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
}
loadBodies() {
return Promise.resolve([]);
}
loadExoplanets() {
return Promise.resolve([]);
}
}
describe('HudDockComponent', () => {
let fixture: ComponentFixture<HudDockComponent>;
function host(): HTMLElement {
return fixture.nativeElement as HTMLElement;
}
function tabNames(): string[] {
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
}
function tab(name: string): HTMLButtonElement {
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
if (!found) {
throw new Error(`no "${name}" tab`);
}
return found;
}
function setReadout(): void {
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' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HudDockComponent],
providers: [
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).compileComponents();
fixture = TestBed.createComponent(HudDockComponent);
});
it('offers only the search when it has nothing to read out and no layers', () => {
fixture.detectChanges();
expect(tabNames()).toEqual(['Search']);
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
});
it('grows a tab per thing it has been given', () => {
setReadout();
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Display']);
});
it('opens the default tab on mount and renders the readout from its inputs', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
const text = host().textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
expect(text).toContain(expected);
}
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
});
it('leaves out the optional lines it was given nothing for', () => {
fixture.componentRef.setInput('title', 'Local Stars');
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('dl')).toBeNull();
expect(host().textContent).not.toContain('undefined');
});
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
expect(host().textContent).toContain('21.5 kpc');
fixture.componentRef.setInput('range', '');
fixture.detectChanges();
expect(host().textContent).not.toContain('Range');
});
it('toggles a tab closed when it is clicked while open', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
tab('Readout').click();
fixture.detectChanges();
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
tab('Search').click();
fixture.detectChanges();
expect(host().querySelector('input[type="text"]')).not.toBeNull();
});
it('emits the flipped layer and nothing else', () => {
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const emitted: HudDisplay[] = [];
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
orbits?.click();
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
});
it('reflects the layer state it is given as pressed buttons', () => {
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true']);
});
it('opens the search on "/" from anywhere but a text field', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
fixture.detectChanges();
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
});
it('hands the panel back to the readout once a result is picked', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
fixture.componentInstance.onPicked();
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
});
});
+231
View File
@@ -0,0 +1,231 @@
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
import { SearchComponent } from '../search/search.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;
}
/** Which layers the scene draws. Every one is a real object in the scene, toggled by visibility. */
export interface HudDisplay {
readonly labels: boolean;
readonly orbits: boolean;
readonly grid: boolean;
readonly deepSky: boolean;
readonly sky: boolean;
}
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true };
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'labels', label: 'Labels' },
{ key: 'orbits', label: 'Orbits' },
{ key: 'grid', label: 'Grid' },
{ key: 'deepSky', label: 'Deep sky' },
{ key: 'sky', label: 'Sky' }
];
export type DockTab = 'search' | 'readout' | 'display';
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', display: 'Display' };
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
const WIDE_VIEWPORT = '(min-width: 640px)';
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
function isWideViewport(): boolean {
return wideViewportQuery?.matches ?? true;
}
/**
* The instrument's dock: one rail across the bottom of the viewport carrying every tool and
* readout, so the top of the screen keeps only the scale ladder and the nameplate.
*
* The tab strip is pinned to the bottom edge and never moves; whichever panel is open grows
* upward from it. That is why the search field sits at the *bottom* of its panel with the
* results above — the thing being typed into stays put while the list grows.
*
* `null` for the active tab is a real state, not an error: the strip alone. It is the default
* below `sm`, where the panel would cover most of the scene, and wherever there is no readout
* to show by default. Purely presentational: readouts and layer state arrive as inputs, the
* only things it emits are layer toggles.
*/
@Component({
selector: 'app-hud-dock',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SearchComponent],
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
template: `
<div class="pointer-events-auto flex flex-col items-start">
@if (activeTab(); as tab) {
<!-- Switching tabs remounts the panel and replays its acquire wipe: a new readout
locking on, once per switch, never per keystroke. -->
@switch (tab) {
@case ('search') {
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire mb-2 w-full max-w-xl">
<app-search (picked)="onPicked()" />
</section>
}
@case ('readout') {
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface mb-2 w-full 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>
}
</section>
}
@case ('display') {
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface mb-2 w-full max-w-lg px-4 py-3">
<p class="type-label text-muted">Layers</p>
<div class="mt-2 flex flex-wrap gap-2">
@for (layer of layers; track layer.key) {
<button
type="button"
[attr.aria-pressed]="isOn(layer.key)"
(click)="toggleLayer(layer.key)"
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="isOn(layer.key) ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
>
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
<span aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span>
{{ layer.label }}
</button>
}
</div>
</section>
}
}
}
<div class="hud-brackets hud-surface flex w-full items-stretch">
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
@for (tab of tabs(); track tab) {
<button
type="button"
role="tab"
[id]="'dock-tab-' + tab"
[attr.aria-selected]="activeTab() === tab"
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
(click)="toggleTab(tab)"
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
>
{{ tabLabel(tab) }}
</button>
}
</div>
@if (range()) {
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
<span class="type-label text-muted">Range</span>
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
</p>
}
</div>
</div>
`
})
export class HudDockComponent implements OnInit {
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
readonly eyebrow = input('');
readonly title = input('');
readonly subtitle = input('');
readonly readouts = input<readonly HudReadout[]>([]);
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
readonly note = input('');
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
readonly range = input('');
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
readonly display = input<HudDisplay | null>(null);
/** Which panel is open on a wide viewport when the dock mounts. */
readonly defaultTab = input<DockTab | null>(null);
readonly displayChange = output<HudDisplay>();
readonly layers = DISPLAY_LAYERS;
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
readonly tabs = computed<readonly DockTab[]>(() => [
'search',
...(this.title() ? (['readout'] as const) : []),
...(this.display() ? (['display'] as const) : [])
]);
readonly activeTab = signal<DockTab | null>(null);
private readonly search = viewChild(SearchComponent);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
ngOnInit(): void {
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
}
tabLabel(tab: DockTab): string {
return TAB_LABELS[tab];
}
isOn(key: keyof HudDisplay): boolean {
return this.display()?.[key] ?? false;
}
toggleTab(tab: DockTab): void {
this.activeTab.set(this.activeTab() === tab ? null : tab);
}
toggleLayer(key: keyof HudDisplay): void {
const current = this.display();
if (current) {
this.displayChange.emit({ ...current, [key]: !current[key] });
}
}
onPicked(): void {
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
// readout where there is one, and fold the sheet away where there is not.
this.activeTab.set(this.title() ? 'readout' : null);
}
/** `/` opens the search from anywhere, unless something else is already taking text. */
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
event.preventDefault();
this.activeTab.set('search');
// The field only exists after the panel renders; defer the focus to after that pass.
setTimeout(() => this.search()?.focus());
}
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
@HostListener('document:pointerdown', ['$event'])
onDocumentPointerDown(event: PointerEvent): void {
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
this.activeTab.set(null);
}
}
}
+19 -6
View File
@@ -1,4 +1,4 @@
import { Component, computed, signal } from '@angular/core';
import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service';
@@ -25,13 +25,17 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
selector: 'app-search',
imports: [ReticleIconComponent],
template: `
<div class="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<!-- Sits on the dock's tab strip: the field is pinned to the bottom of the block and the
results grow upward above it, so typing never moves the thing being typed into. DOM
order stays field-then-results (flex-col-reverse), which is also the focus order. -->
<div class="flex flex-col-reverse font-body">
<!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
the star field. -->
<div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50">
<app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" />
<input
#field
type="text"
placeholder="Search stars, planets, exoplanets…"
[value]="query()"
@@ -42,11 +46,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
</div>
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
the rows instead of remounting the panel and replaying its acquire wipe per
keystroke. Gated on the index being ready: "no matches" may only ever describe a
search that actually ran against the loaded catalogues. -->
the rows instead of remounting the panel. Gated on the index being ready: "no matches"
may only ever describe a search that actually ran against the loaded catalogues. -->
@if (indexReady() && hasQuery()) {
<div class="hud-acquire hud-surface mt-2">
<div class="hud-surface mb-2">
@if (results().length) {
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
@@ -78,6 +81,11 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
})
export class SearchComponent {
readonly query = signal('');
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
* the view back to the readout, since the thing to look at is now the scene, not the box. */
readonly picked = output<void>();
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]);
/** False until the catalogues have loaded — and forever if they fail, which beats a false
@@ -117,6 +125,10 @@ export class SearchComponent {
this.query.set('');
}
focus(): void {
this.field().nativeElement.focus();
}
kindLabel(kind: SearchResultKind): string {
return KIND_LABELS[kind];
}
@@ -129,6 +141,7 @@ export class SearchComponent {
} else if (result.bodyId) {
void this.router.navigate(['/body', result.bodyId]);
}
this.picked.emit();
}
private async buildIndex(): Promise<void> {