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
+2 -1
View File
@@ -10,7 +10,8 @@ test.describe('Galaxy view', () => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible(); 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); await expect(backButtonLocator(page)).toHaveCount(0);
// The readout panel's own title, not just the text anywhere on screen: the selected-object // 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. // 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 { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
import { backButtonLocator } from './support/wait-for-back-button'; import { backButtonLocator } from './support/wait-for-back-button';
test.describe('Search-driven navigation', () => { test.describe('Search-driven navigation', () => {
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => { test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('Proxima Centauri'); await searchInput.fill('Proxima Centauri');
const result = page.getByRole('button', { name: /Proxima Centauri/ }); const result = page.getByRole('button', { name: /Proxima Centauri/ });
await expect(result).toBeVisible(); await expect(result).toBeVisible();
await result.click(); await result.click();
// Selecting a result clears the search query immediately (before the flight even starts). // Selecting a result hands the dock straight back to the readout (before the flight even
await expect(searchInput).toHaveValue(''); // 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 }); 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 }) => { test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('Earth'); await searchInput.fill('Earth');
const result = page.getByText('Earth', { exact: true }); 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 }) => { test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('E'); await searchInput.fill('E');
await expect(page.getByTestId('search-results')).toHaveCount(0); await expect(page.getByTestId('search-results')).toHaveCount(0);
+10
View File
@@ -0,0 +1,10 @@
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> {
await page.getByRole('tab', { name: 'Search' }).click();
return page.getByPlaceholder('Search stars, planets, exoplanets…');
}
-1
View File
@@ -4,5 +4,4 @@
aria-hidden="true" aria-hidden="true"
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]" class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
></div> ></div>
<app-search></app-search>
<router-outlet></router-outlet> <router-outlet></router-outlet>
+1 -3
View File
@@ -1,11 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SearchComponent } from './features/search/search.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, SearchComponent], imports: [RouterOutlet],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
@@ -14,6 +14,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { HudDockComponent } from '../hud/hud-dock.component';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model'; import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component'; import { InfoPanelComponent } from './info-panel.component';
@@ -37,14 +38,14 @@ const GLOW_SCALE = 2.6;
@Component({ @Component({
selector: 'app-body-detail-scene', selector: 'app-body-detail-scene',
providers: [EngineService], providers: [EngineService],
imports: [ChevronIconComponent, InfoPanelComponent, RouterLink], imports: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) { @if (viewModel()) {
<app-info-panel [body]="viewModel()!" /> <app-info-panel [body]="viewModel()!" />
} @else if (notFound()) { } @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="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> <p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
<a <a
@@ -56,6 +57,8 @@ const GLOW_SCALE = 2.6;
</a> </a>
</div> </div>
} }
<!-- Search only: there is no scene readout here, the info panel is the reading. -->
<app-hud-dock />
</div> </div>
` `
}) })
@@ -19,10 +19,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
selector: 'app-info-panel', selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent], imports: [ChevronIconComponent, ReadoutSectionsComponent],
template: ` template: `
<!-- Sits below the search field until xl, beside it from there. The search field is 26rem <!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. -->
wide and centred, so this right-anchored 20rem panel only clears it once the viewport <div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
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">
<button <button
type="button" type="button"
(click)="goBack()" (click)="goBack()"
@@ -22,7 +22,8 @@ import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramin
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity'; import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-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 { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
@@ -151,24 +152,28 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
@Component({ @Component({
selector: 'app-galaxy-system-scene', selector: 'app-galaxy-system-scene',
providers: [EngineService], providers: [EngineService],
imports: [StarmapHudComponent, SystemObjectCardComponent], imports: [HudDockComponent, StarmapHudComponent, SystemObjectCardComponent],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <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> <!-- isolate: CSS2DRenderer gives every label its own z-index for depth ordering; without a
<app-starmap-hud stacking context here those indices escape and the labels paint over the HUD. -->
[level]="navigationStore.viewLevel()" <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()" [eyebrow]="hudEyebrow()"
[title]="hudTitle()" [title]="hudTitle()"
[subtitle]="hudSubtitle()" [subtitle]="hudSubtitle()"
[readouts]="hudReadouts()" [readouts]="hudReadouts()"
[note]="hudNote()" [note]="hudNote()"
[range]="hudRange()" [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> </div>
` `
}) })
@@ -190,6 +195,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
readonly hudReadouts = signal<readonly HudReadout[]>([]); readonly hudReadouts = signal<readonly HudReadout[]>([]);
readonly hudNote = signal(''); readonly hudNote = signal('');
readonly hudRange = 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 * 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); this.reconcileSelection(selectedStarId);
} }
}); });
effect(() => this.applyDisplay(this.display()));
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@@ -393,6 +401,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.labelOverlay = new StarLabelOverlay(scene); this.labelOverlay = new StarLabelOverlay(scene);
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement); this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
this.applyDisplay(this.display());
const { width, height } = canvas.getBoundingClientRect(); const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height); this.labelOverlay.setSize(width, height);
@@ -453,14 +462,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const distancePc = camera.position.length(); const distancePc = camera.position.length();
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc); this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
this.galacticGrid?.setStrength(this.galacticStrength); // Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
this.localGrid?.setStrength(1 - this.galacticStrength); // `setStrength` rewrites visibility every frame from the strength it is given, so a hidden
this.tethers?.setStrength(1 - this.galacticStrength); // 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. // 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 // 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. // 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); this.applyGalaxyDepthRange(camera, distancePc);
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy'; 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. */ /** 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 { private updateHud(camera: THREE.PerspectiveCamera): void {
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId); 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); const hostLuminosity = luminosityOf(star);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
this.systemGroup.add(this.systemRenderer.object); 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 // 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 // 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']); 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', () => { it('names what the view is holding on the banner across the top', () => {
fixture.componentRef.setInput('level', 'system'); fixture.componentRef.setInput('level', 'system');
fixture.componentRef.setInput('title', 'Sol'); 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 { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component'; 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 { interface LadderStep {
readonly level: ViewLevel; readonly level: ViewLevel;
readonly label: string; 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 * The top of the map's heads-up display: the scale ladder on the left, the nameplate across
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around * the centre, and a centre reticle on whatever the camera is holding. Readouts and tools live
* the whole viewport. * in the dock along the bottom (`HudDockComponent`).
* *
* Purely presentational — every value arrives as an input and the only thing it emits is a * 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. * 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 <!-- 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. --> 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-16 left-6 flex items-stretch divide-x divide-border/40"> <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) { @for (step of ladder(); track step.level) {
@if (step.reachable) { @if (step.reachable) {
<button <button
@@ -84,58 +73,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@if (title()) { @if (title()) {
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a <!-- 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 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. --> right — all three share the top-6 line. -->
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block"> <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"> <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" /> <app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span> <span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
</div> </div>
</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 { export class StarmapHudComponent {
readonly level = input.required<ViewLevel>(); 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 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 showReticle = input(true);
readonly levelSelected = output<ViewLevel>(); 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: 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 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. --> 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 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"> <div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<header class="min-w-0"> <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 { function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
const points = orbitEllipsePoints(elements); const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3); 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] 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); 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 { dispose(): void {
this.grid?.dispose(); this.grid?.dispose();
this.tethers?.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');
});
});
+229
View File
@@ -0,0 +1,229 @@
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)';
function isWideViewport(): boolean {
return typeof window === 'undefined' || typeof window.matchMedia !== 'function' ? true : window.matchMedia(WIDE_VIEWPORT).matches;
}
/**
* 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 { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
@@ -25,13 +25,17 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
selector: 'app-search', selector: 'app-search',
imports: [ReticleIconComponent], imports: [ReticleIconComponent],
template: ` 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 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 ring is the keyboard-focus indicator: a border hue shift alone is invisible over
the star field. --> 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"> <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" /> <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 <input
#field
type="text" type="text"
placeholder="Search stars, planets, exoplanets…" placeholder="Search stars, planets, exoplanets…"
[value]="query()" [value]="query()"
@@ -42,11 +46,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
</div> </div>
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only <!-- 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 the rows instead of remounting the panel. Gated on the index being ready: "no matches"
keystroke. Gated on the index being ready: "no matches" may only ever describe a may only ever describe a search that actually ran against the loaded catalogues. -->
search that actually ran against the loaded catalogues. -->
@if (indexReady() && hasQuery()) { @if (indexReady() && hasQuery()) {
<div class="hud-acquire hud-surface mt-2"> <div class="hud-surface mb-2">
@if (results().length) { @if (results().length) {
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted"> <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> Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
@@ -78,6 +81,11 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
}) })
export class SearchComponent { export class SearchComponent {
readonly query = signal(''); 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. */ /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]); private readonly index = signal<IndexedSearchEntry[]>([]);
/** False until the catalogues have loaded — and forever if they fail, which beats a false /** 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(''); this.query.set('');
} }
focus(): void {
this.field().nativeElement.focus();
}
kindLabel(kind: SearchResultKind): string { kindLabel(kind: SearchResultKind): string {
return KIND_LABELS[kind]; return KIND_LABELS[kind];
} }
@@ -129,6 +141,7 @@ export class SearchComponent {
} else if (result.bodyId) { } else if (result.bodyId) {
void this.router.navigate(['/body', result.bodyId]); void this.router.navigate(['/body', result.bodyId]);
} }
this.picked.emit();
} }
private async buildIndex(): Promise<void> { private async buildIndex(): Promise<void> {