09eaf95 folded the Display sheet away on a narrow viewport once a date was set, and said the
strip then read the date. It did only in its text: the dock's tab list could not shrink, the
system view's five tabs take 397 px, and at 360 by 640 and 390 by 844 they pushed the date strip
to x 406-511, past the right edge of a page that does not scroll. With the sheet folded, the set
date was nowhere on screen; before the fold, the open field had at least shown it. And the fold
removed the form that held focus, so focus fell to the page and the next Tab started again at
Search, with nothing announcing the date.
The tab list now gives way (min-w-0) and scrolls (overflow-x-auto), and the date and range strips
keep their width (shrink-0). After a date is set on a phone, focus goes to the tab whose panel
folded, so Enter opens it again.
Live on :4301 in the Sun's system, Display, 2020-12-21T18:00, Enter: at 360x640 the panel folds,
the strip "Date 2020-12-21" sits at x 82-230 and the tabs scroll in 73 px (397 of content); at
390x844 the strip is at 112-260 and the tabs scroll in 103; page scrollWidth equals the viewport
at both, and the tab row stays 36 px high. Focus is on #dock-tab-display at both, and Enter
reopens the panel. At 1400x900 nothing changes: the panel stays open, focus stays in the field,
the tabs take their 437 px. Screenshots dock-go-360x640.png and dock-go-390x844.png, in the
review's scratchpad (wave1/solar/fixr2).
Tests: the tab list carries min-w-0 and overflow-x-auto and the date strip shrink-0 (jsdom lays
nothing out, so the classes are what can be checked there; the positions above are the app's),
and after Go on a phone the focused element is #dock-tab-display. Guarded mutants (full suite),
each failing only its named test: min-w-0 and overflow-x-auto removed, shrink-0 removed from the
date strip ('keeps the date strip on screen on a phone'), and the focus call removed ('hands
focus to the tab that folded').
Unit suite 864/864, tsc -p tsconfig.app.json and etl:typecheck clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
634 lines
27 KiB
TypeScript
634 lines
27 KiB
TypeScript
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<DockTab, string> = {
|
||
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: `
|
||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||
click in the empty band beside the panel — where the scene, and its labels, are. -->
|
||
<div class="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 pointer-events-auto 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 pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||
>
|
||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||
<div class="mt-1 flex items-start gap-2">
|
||
<p
|
||
data-testid="hud-title"
|
||
class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase"
|
||
>
|
||
{{ title() }}
|
||
</p>
|
||
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
||
truthiness test is what would quietly make the Solar System the one
|
||
system nobody could keep. -->
|
||
@if (keepableStarId() !== null) {
|
||
<button
|
||
type="button"
|
||
[attr.aria-label]="
|
||
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
|
||
"
|
||
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||
(click)="
|
||
bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })
|
||
"
|
||
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||
[class]="
|
||
bookmarks.has('star', keepableStarId()!)
|
||
? 'text-accent'
|
||
: 'text-muted hover:text-accent'
|
||
"
|
||
>
|
||
<app-bookmark-icon
|
||
class="h-3.5 w-3.5"
|
||
[kept]="bookmarks.has('star', keepableStarId()!)"
|
||
/>
|
||
</button>
|
||
}
|
||
</div>
|
||
@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 ('bookmarks') {
|
||
<section
|
||
id="dock-panel-bookmarks"
|
||
role="tabpanel"
|
||
aria-labelledby="dock-tab-bookmarks"
|
||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg"
|
||
>
|
||
@if (bookmarks.bookmarks().length) {
|
||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||
@for (
|
||
bookmark of bookmarks.bookmarks();
|
||
track bookmark.kind + ':' + bookmark.id
|
||
) {
|
||
<li class="flex items-stretch">
|
||
<button
|
||
type="button"
|
||
(click)="onBookmarkChosen(bookmark)"
|
||
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||
>
|
||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{
|
||
bookmark.name
|
||
}}</span>
|
||
<span class="type-label shrink-0 text-muted">{{
|
||
bookmark.kind === 'star' ? 'System' : 'Body'
|
||
}}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
[attr.aria-label]="'Forget ' + bookmark.name"
|
||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||
>
|
||
<svg
|
||
class="h-3 w-3"
|
||
viewBox="0 0 14 14"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M3 3l8 8M11 3l-8 8" />
|
||
</svg>
|
||
</button>
|
||
</li>
|
||
}
|
||
</ul>
|
||
} @else {
|
||
<p class="px-3 py-3 text-sm text-muted">
|
||
Nothing kept yet. The
|
||
<app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a
|
||
readout or a body keeps it here, in this browser.
|
||
</p>
|
||
}
|
||
</section>
|
||
}
|
||
@case ('display') {
|
||
<section
|
||
id="dock-panel-display"
|
||
role="tabpanel"
|
||
aria-labelledby="dock-tab-display"
|
||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||
>
|
||
@if (display()) {
|
||
<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>
|
||
}
|
||
|
||
<!-- The clock. Orbits and rotations are both functions of a date, so this is the
|
||
difference between a still picture and an orrery. -->
|
||
<p class="type-label text-muted" [class.mt-4]="display()">Clock</p>
|
||
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
||
carries that to a screen reader and to the arrow keys without any script. -->
|
||
<div
|
||
class="mt-2 flex flex-wrap items-center gap-2"
|
||
role="radiogroup"
|
||
aria-label="Clock rate"
|
||
>
|
||
<!-- A rate is picked by its size and the toggle after the radios says which way it
|
||
runs, so a month a second backwards is the same radio as forwards. -->
|
||
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||
<label
|
||
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
||
[class]="
|
||
Math.abs(time.rate()) === rate.secondsPerSecond
|
||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||
"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="clock-rate"
|
||
class="sr-only"
|
||
[value]="rate.secondsPerSecond"
|
||
[checked]="Math.abs(time.rate()) === rate.secondsPerSecond"
|
||
(change)="time.setRate(Math.sign(time.rate()) * rate.secondsPerSecond)"
|
||
/>
|
||
{{ rate.label }}
|
||
</label>
|
||
}
|
||
<button
|
||
type="button"
|
||
[attr.aria-pressed]="time.rate() < 0"
|
||
(click)="time.setRate(-time.rate())"
|
||
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]="
|
||
time.rate() < 0
|
||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||
"
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
class="h-1.5 w-1.5 border border-current"
|
||
[class.bg-current]="time.rate() < 0"
|
||
></span>
|
||
Backwards
|
||
</button>
|
||
@if (!time.atNow()) {
|
||
<button
|
||
type="button"
|
||
(click)="backToNow()"
|
||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||
>
|
||
Back to now
|
||
</button>
|
||
}
|
||
</div>
|
||
<!-- A form, so Enter in the field goes there and the browser holds the field to its
|
||
min and max before anything is submitted. Submitted rather than applied on each
|
||
change: Chrome reports a year typed digit by digit as 0002, 0020, 0202 and 2020,
|
||
and the sky would jump through every one. -->
|
||
<form class="mt-2 flex flex-wrap items-center gap-2" (submit)="goToDate($event)">
|
||
<label for="clock-date" class="type-label text-muted">Date (UTC)</label>
|
||
<input
|
||
id="clock-date"
|
||
name="date"
|
||
type="datetime-local"
|
||
required
|
||
[min]="clockWindow.min"
|
||
[max]="clockWindow.max"
|
||
[value]="dateField()"
|
||
aria-describedby="clock-date-window"
|
||
class="hud-surface min-w-0 flex-1 px-2.5 py-1 text-sm text-text tabular-nums caret-accent scheme-dark focus:border-accent focus:outline-none sm:flex-none"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||
>
|
||
Go
|
||
</button>
|
||
<!-- One line: on a phone the panel is a sheet over the system it sets the date of, and
|
||
each card already says how far its own orbit strays. -->
|
||
<p id="clock-date-window" class="w-full text-[10px] text-muted">
|
||
AD 1 to AD 3000, where the planets’ elements hold.
|
||
</p>
|
||
</form>
|
||
</section>
|
||
}
|
||
}
|
||
}
|
||
<!-- Hidden rather than unmounted: the departure, destination and range it holds would
|
||
otherwise reset on every trip to another tab, while the scene kept drawing the graph at
|
||
the old range. No acquire wipe, unlike the panels around it: this is the one that comes
|
||
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
||
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
||
@if (routing()) {
|
||
<section
|
||
id="dock-panel-routes"
|
||
role="tabpanel"
|
||
aria-labelledby="dock-tab-routes"
|
||
[hidden]="activeTab() !== 'routes'"
|
||
class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3"
|
||
>
|
||
<app-routes-panel
|
||
[result]="routeResult()"
|
||
[pending]="routePending()"
|
||
[options]="routeOptions()"
|
||
[currentStar]="currentStar()"
|
||
(queryChange)="routeQuery.emit($event)"
|
||
(routeRequested)="routeRequested.emit($event)"
|
||
(starSelected)="onRouteStarSelected($event)"
|
||
(rangeChange)="jumpRangeChange.emit($event)"
|
||
/>
|
||
</section>
|
||
}
|
||
|
||
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
|
||
<!-- The tabs give way to the date and the range, and scroll: the five of the system view
|
||
take 397 px, and on a portrait phone they pushed the date off the right edge. -->
|
||
<div role="tablist" aria-label="Dock" class="flex min-w-0 items-stretch divide-x divide-border/40 overflow-x-auto">
|
||
@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>
|
||
<!-- Only while the map is away from the present: at the present the date is
|
||
today's, which the reader's own machine already says. -->
|
||
@if (date()) {
|
||
<p
|
||
class="ml-auto flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||
data-testid="hud-date"
|
||
>
|
||
<span class="type-label text-muted">Date</span>
|
||
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||
</p>
|
||
}
|
||
@if (range()) {
|
||
<p
|
||
class="flex shrink-0 items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||
[class.ml-auto]="!date()"
|
||
>
|
||
<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('');
|
||
/** 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<HudDisplay | null>(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<DockTab | null>(null);
|
||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||
readonly routeResult = input<RouteResult | null>(null);
|
||
readonly routePending = input(false);
|
||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||
readonly currentStar = input<RouteStarOption | null>(null);
|
||
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
||
readonly keepableStarId = input<number | null>(null);
|
||
/** Present makes the Routes tab available; absent means this surface cannot route. */
|
||
readonly routing = input(false);
|
||
|
||
readonly displayChange = output<HudDisplay>();
|
||
readonly routeQuery = output<string>();
|
||
readonly routeRequested = output<RouteRequest>();
|
||
readonly routeStarSelected = output<number>();
|
||
readonly jumpRangeChange = output<number>();
|
||
readonly bookmarkChosen = output<Bookmark>();
|
||
|
||
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.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<DockTab | null>(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<HTMLElement>>(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<HTMLElement>(`#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);
|
||
}
|
||
}
|
||
}
|