import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild, } from '@angular/core'; import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store'; import { CLOCK_WINDOW, TIME_RATES, TimeStore } from '../../shared/state/time.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; /** Drawn flat: an orthographic projection, looking down on the plane. */ readonly plan: boolean; } export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: 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' }, { key: 'plan', label: 'Plan view' }, ]; 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 ('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') {
@if (display()) {

Layers

@for (layer of layers; track layer.key) { }
}

Clock

@for (rate of timeRates; track rate.secondsPerSecond) { } @if (!time.atNow()) { }

AD 1 to AD 3000, where the planets’ elements hold.

} } } @if (routing()) {
}
@for (tab of tabs(); track tab) { }
@if (date()) {

Date {{ date() }}

} @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(''); /** The date the sky is drawn for; empty while the map is drawn for the present. */ readonly date = input(''); /** * Layer state; `null` means the surface has no layers to toggle, and no Display tab unless it * has the clock. */ readonly display = input(null); /** The clock without the layers, for a surface that is drawn at its date: the tab is then "Clock". */ readonly clock = input(false); /** 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 routePending = input(false); 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() || this.clock() ? (['display'] as const) : []), ]); readonly activeTab = signal(null); readonly bookmarks = inject(BookmarksStore); readonly time = inject(TimeStore); readonly timeRates = TIME_RATES; readonly clockWindow = CLOCK_WINDOW; protected readonly Math = Math; /** * What the date field holds when the panel opens: the clock's date at that moment. Not bound to * the running clock, which would rewrite the field under the reader's typing on every render. */ readonly dateField = signal(''); private readonly search = viewChild(SearchComponent); private readonly host = inject>(ElementRef); ngOnInit(): void { this.activeTab.set(isWideViewport() ? this.defaultTab() : null); this.fillDateField(); } tabLabel(tab: DockTab): string { return tab === 'display' && !this.display() ? 'Clock' : 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); this.fillDateField(); } /** The field's value is read as UTC, which is what the strip and the note print dates in. */ goToDate(event: SubmitEvent): void { event.preventDefault(); const field = (event.target as HTMLFormElement).elements.namedItem('date') as HTMLInputElement; if (this.time.setDate(new Date(`${field.value}Z`))) { // The field now says what the signal behind it does, so a later reset that fills it with // the present is a change the binding writes back, not one it drops as the same value. this.dateField.set(field.value); // On a phone the sheet covers the system it has just set the date of: at 360 by 640 every // orbit lies behind it. The thing to look at is now the scene, as after a search. // Focus goes back to the tab that folded, not to the page: the form it was in is gone. if (!isWideViewport()) { const tab = this.activeTab(); this.activeTab.set(null); this.host.nativeElement.querySelector(`#dock-tab-${tab}`)?.focus(); } } } backToNow(): void { this.time.reset(); this.fillDateField(); } private fillDateField(): void { this.dateField.set(this.time.date().toISOString().slice(0, 16)); } 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); } } }