From 4a1cc5240abc3bd6a0b5648de39419283084fd43 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Tue, 18 Aug 2026 20:57:10 +0200 Subject: [PATCH] Dock the HUD: every tool and readout on one rail along the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- e2e/galaxy-view.spec.ts | 3 +- e2e/search-navigation.spec.ts | 13 +- e2e/support/open-search.ts | 10 + src/app/app.html | 1 - src/app/app.ts | 4 +- .../body-detail-scene.component.ts | 7 +- .../body-detail/info-panel.component.ts | 6 +- .../galaxy-system-scene.component.ts | 54 +++-- .../starmap-hud.component.spec.ts | 22 -- .../galaxy-system/starmap-hud.component.ts | 65 +---- .../system-object-card.component.ts | 2 +- .../galaxy-system/system-orbits-renderer.ts | 22 +- .../features/hud/hud-dock.component.spec.ts | 159 ++++++++++++ src/app/features/hud/hud-dock.component.ts | 229 ++++++++++++++++++ src/app/features/search/search.component.ts | 25 +- 15 files changed, 505 insertions(+), 117 deletions(-) create mode 100644 e2e/support/open-search.ts create mode 100644 src/app/features/hud/hud-dock.component.spec.ts create mode 100644 src/app/features/hud/hud-dock.component.ts diff --git a/e2e/galaxy-view.spec.ts b/e2e/galaxy-view.spec.ts index 227b5ad..b9e31c9 100644 --- a/e2e/galaxy-view.spec.ts +++ b/e2e/galaxy-view.spec.ts @@ -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. diff --git a/e2e/search-navigation.spec.ts b/e2e/search-navigation.spec.ts index 9d058b4..4012b6b 100644 --- a/e2e/search-navigation.spec.ts +++ b/e2e/search-navigation.spec.ts @@ -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); diff --git a/e2e/support/open-search.ts b/e2e/support/open-search.ts new file mode 100644 index 0000000..24e9ca4 --- /dev/null +++ b/e2e/support/open-search.ts @@ -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 { + await page.getByRole('tab', { name: 'Search' }).click(); + return page.getByPlaceholder('Search stars, planets, exoplanets…'); +} diff --git a/src/app/app.html b/src/app/app.html index 5e07294..6b94eac 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -4,5 +4,4 @@ aria-hidden="true" class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]" > - diff --git a/src/app/app.ts b/src/app/app.ts index 423ed46..fb4c9d5 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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' }) diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 71a8f29..b9b58c7 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -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: `
@if (viewModel()) { } @else if (notFound()) { -
+

No record

That id isn't in the catalog — it may have been renamed or mistyped.

} + +
` }) diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 0cdbda9..7a0603a 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -19,10 +19,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; selector: 'app-info-panel', imports: [ChevronIconComponent, ReadoutSectionsComponent], template: ` - -
+ +
` }) @@ -190,6 +195,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { readonly hudReadouts = signal([]); readonly hudNote = signal(''); readonly hudRange = signal(''); + /** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */ + readonly display = signal(DEFAULT_HUD_DISPLAY); /** * The body whose card is showing: whichever is pinned by a click, else whatever the pointer is @@ -246,6 +253,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.reconcileSelection(selectedStarId); } }); + effect(() => this.applyDisplay(this.display())); } ngAfterViewInit(): void { @@ -393,6 +401,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.labelOverlay = new StarLabelOverlay(scene); this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement); + this.applyDisplay(this.display()); const { width, height } = canvas.getBoundingClientRect(); this.labelOverlay.setSize(width, height); @@ -453,14 +462,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const distancePc = camera.position.length(); this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc); - this.galacticGrid?.setStrength(this.galacticStrength); - this.localGrid?.setStrength(1 - this.galacticStrength); - this.tethers?.setStrength(1 - this.galacticStrength); + // Layer toggles from the dock fold in here rather than as a one-off `visible = false`: + // `setStrength` rewrites visibility every frame from the strength it is given, so a hidden + // layer has to be told a strength of zero every frame too. + const display = this.display(); + this.galacticGrid?.setStrength(display.grid ? this.galacticStrength : 0); + this.localGrid?.setStrength(display.grid ? 1 - this.galacticStrength : 0); + this.tethers?.setStrength(display.grid ? 1 - this.galacticStrength : 0); // The backdrop shell is the sky as seen from here; from outside it, it is a wall. - this.deepSky?.setStrength(1 - this.galacticStrength); + this.deepSky?.setStrength(display.deepSky ? 1 - this.galacticStrength : 0); // Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken // from inside it, so it cannot also be the sky behind a view of the Galaxy from outside. - this.engine.getScene().backgroundIntensity = 1 - this.galacticStrength; + this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0; this.applyGalaxyDepthRange(camera, distancePc); const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy'; @@ -633,6 +646,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } /** Refreshes the readout panel for whichever scale the view is currently at. */ + /** + * Shows or hides the layers that hold still between frames: the label layer and the system + * view's orbits and grid. The galaxy grids, deep-sky shell and skybox are crossfaded every + * frame instead, so their toggles live in `updateGalacticCrossfade`. + */ + private applyDisplay(display: HudDisplay): void { + if (this.labelOverlay) { + this.labelOverlay.domElement.style.display = display.labels ? '' : 'none'; + } + this.systemRenderer?.setLayerVisibility({ orbits: display.orbits, grid: display.grid }); + } + private updateHud(camera: THREE.PerspectiveCamera): void { const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId); @@ -878,6 +903,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const hostLuminosity = luminosityOf(star); this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemGroup.add(this.systemRenderer.object); + this.applyDisplay(this.display()); // Framed against the grid's outer ring rather than the outermost orbit — the ring is always // the wider of the two — and against the camera this scene actually has, so the margin holds diff --git a/src/app/features/galaxy-system/starmap-hud.component.spec.ts b/src/app/features/galaxy-system/starmap-hud.component.spec.ts index 980dce8..f77dad1 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.spec.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.spec.ts @@ -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'); diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index cc9371d..9a0f2c7 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -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 }[] = [ } -
+ + } + } + } + +
+
+ @for (tab of tabs(); track tab) { + + } +
+ @if (range()) { +

+ Range + {{ range() }} +

+ } +
+
+ ` +}) +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([]); + /** 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(null); + /** Which panel is open on a wide viewport when the dock mounts. */ + readonly defaultTab = input(null); + + readonly displayChange = output(); + + readonly layers = DISPLAY_LAYERS; + readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived)); + readonly tabs = computed(() => [ + 'search', + ...(this.title() ? (['readout'] as const) : []), + ...(this.display() ? (['display'] as const) : []) + ]); + + readonly activeTab = signal(null); + + private readonly search = viewChild(SearchComponent); + private readonly host = inject>(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); + } + } +} diff --git a/src/app/features/search/search.component.ts b/src/app/features/search/search.component.ts index 9f70049..08b052c 100644 --- a/src/app/features/search/search.component.ts +++ b/src/app/features/search/search.component.ts @@ -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 = { selector: 'app-search', imports: [ReticleIconComponent], template: ` -
+ +
= {
+ 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()) { -
+
@if (results().length) {

Matches {{ matchTotal() }} @@ -78,6 +81,11 @@ const KIND_LABELS: Record = { }) 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(); + + private readonly field = viewChild.required>('field'); /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ private readonly index = signal([]); /** 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 {