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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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…');
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user