import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core'; import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store'; import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { SearchComponent } from '../search/search.component'; import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.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; /** The rings on stars known to host planets. */ readonly systems: boolean; /** The graph of crossings within the range the Routes panel is set to. */ readonly jumpLinks: boolean; } export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false }; 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' }, { key: 'systems', label: 'Systems' }, { key: 'jumpLinks', label: 'Jump links' } ]; export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display'; const TAB_LABELS: Record = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', 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: [BookmarkIconComponent, RoutesPanelComponent, 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: `
@if (activeTab(); as tab) { @switch (tab) { @case ('search') { } @case ('readout') {

{{ eyebrow() }}

{{ title() }}

@if (keepableStarId() !== null) { }
@if (subtitle()) {

{{ subtitle() }}

} @if (readouts().length) {
@for (readout of readouts(); track readout.label) {
{{ readout.label }}@if (readout.derived) {}
{{ readout.value }}
}
} @if (note() || hasDerived()) {

@if (hasDerived()) {* Derived, not catalogued. }{{ note() }}

}
} @case ('routes') {
} @case ('bookmarks') {
@if (bookmarks.bookmarks().length) {
    @for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
  • }
} @else {

Nothing kept yet. The on a readout or a body keeps it here, in this browser.

}
} @case ('display') {

Layers

@for (layer of layers; track layer.key) { }
} } }
@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); /** Routing: what the scene found, what it offers for the fields, and where the view is. */ readonly routeResult = input(null); readonly routeOptions = input([]); readonly currentStar = input(null); /** The star the readout is about, where there is one to keep — a scale is not a place. */ readonly keepableStarId = input(null); /** Present makes the Routes tab available; absent means this surface cannot route. */ readonly routing = input(false); readonly displayChange = output(); readonly routeQuery = output(); readonly routeRequested = output(); readonly routeStarSelected = output(); readonly jumpRangeChange = output(); readonly bookmarkChosen = output(); readonly layers = DISPLAY_LAYERS; readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived)); readonly tabs = computed(() => [ 'search', ...(this.title() ? (['readout'] as const) : []), ...(this.routing() ? (['routes'] as const) : []), // Always offered, even with nothing in it: it is the only place that says the map can keep // anything at all, and a tab that appears once you already know is a tab that never taught. 'bookmarks', ...(this.display() ? (['display'] as const) : []) ]); readonly activeTab = signal(null); readonly bookmarks = inject(BookmarksStore); 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] }); } } /** A step on a plotted route was chosen: fly there, and leave the route up to walk it from. */ onRouteStarSelected(starId: number): void { this.routeStarSelected.emit(starId); } /** Choosing a kept place is the same move as choosing a search result: the panel has done * its job and the thing to look at is now the scene. */ onBookmarkChosen(bookmark: Bookmark): void { this.bookmarkChosen.emit(bookmark); this.onPicked(); } 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. On a narrow // viewport it always folds away — there the panel is a sheet over most of the scene, and // reopening it onto whatever was just flown to is the opposite of what was asked for. this.activeTab.set(isWideViewport() && 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); } } }