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..3cf4d07 --- /dev/null +++ b/e2e/support/open-search.ts @@ -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 { + // 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…'); +} 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,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 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 {