Restyle the HUD as an observatory instrument (#4)

Restyle the HUD as an observatory instrument
This commit is contained in:
Senrokai
2026-08-17 18:17:18 +02:00
committed by GitHub
13 changed files with 345 additions and 185 deletions
+6
View File
@@ -1,2 +1,8 @@
<!-- Viewport frame: the four corner marks that read the whole screen as one instrument, rather
than as unrelated panels floating on black. Decorative, so it never intercepts scene input. -->
<div
aria-hidden="true"
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
></div>
<app-search></app-search> <app-search></app-search>
<router-outlet></router-outlet> <router-outlet></router-outlet>
@@ -13,6 +13,7 @@ import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model'; import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component'; import { InfoPanelComponent } from './info-panel.component';
@@ -36,16 +37,23 @@ const GLOW_SCALE = 2.6;
@Component({ @Component({
selector: 'app-body-detail-scene', selector: 'app-body-detail-scene',
providers: [EngineService], providers: [EngineService],
imports: [InfoPanelComponent, RouterLink], imports: [ChevronIconComponent, InfoPanelComponent, RouterLink],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) { @if (viewModel()) {
<app-info-panel [body]="viewModel()!" /> <app-info-panel [body]="viewModel()!" />
} @else if (notFound()) { } @else if (notFound()) {
<div class="absolute top-4 right-4 rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md"> <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">
<p class="mb-2 text-sm">Couldn't find that body.</p> <p class="type-eyebrow text-accent">No record</p>
<a routerLink="/" class="text-sm text-accent hover:underline">Back to the galaxy</a> <p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
<a
routerLink="/"
class="type-label mt-4 inline-flex items-center gap-2 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-2 focus-visible:outline-accent"
>
<app-chevron-icon class="h-3 w-3" direction="left" />
Back to the galaxy
</a>
</div> </div>
} }
</div> </div>
@@ -1,54 +1,43 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { bodyReadouts } from './body-readouts'; import { bodyReadouts } from './body-readouts';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { ReadoutSectionsComponent } from './readout-sections.component';
/** /**
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical * Displays the real NASA data for the currently selected body/exoplanet: kind, physical
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only — * size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
* `BodyDetailSceneComponent` supplies the view model and owns navigation state. * `BodyDetailSceneComponent` supplies the view model and owns navigation state.
* *
* The rows come from `bodyReadouts`, shared with the system view's object card so the same body * The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the system
* cannot read differently in the two places it can be inspected. * view's object card so the same body cannot read differently in the two places it can be
* inspected.
*/ */
@Component({ @Component({
selector: 'app-info-panel', selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent],
template: ` template: `
<div class="absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md"> <!-- 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">
<button <button
type="button" type="button"
(click)="goBack()" (click)="goBack()"
class="mb-3 flex items-center gap-1.5 rounded-md border border-border bg-panel/60 px-3 py-1.5 text-xs tracking-wide text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50" class="type-label flex w-full items-center gap-2 border-b border-border/40 px-4 py-2 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <app-chevron-icon class="h-3 w-3" direction="left" />
<path d="M15 6l-6 6 6 6" />
</svg>
System System
</button> </button>
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1> <header class="px-4 pt-4 pb-3">
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header>
@if (readouts().measured.length) { <app-readout-sections [readouts]="readouts()" />
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@for (row of readouts().measured; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
}
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@for (row of readouts().derived; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
</div> </div>
` `
}) })
@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { BodyReadouts } from './body-readouts';
/**
* The Measured/Derived rows and the provenance footnote, shared by the detail page's info panel
* and the system view's object card. `bodyReadouts` already guarantees the two surfaces agree
* on the numbers; this guarantees they agree on the pixels — the markup used to be pasted four
* times across the two templates, which is exactly the drift the shared data model exists to
* prevent.
*/
@Component({
selector: 'app-readout-sections',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (readouts().measured.length) {
<p class="type-label border-t border-border/40 px-4 pt-3 pb-1 text-muted">Measured</p>
<dl class="divide-y divide-border/25">
@for (row of readouts().measured; track row.label) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="type-label text-muted">{{ row.label }}</dt>
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
</div>
}
</dl>
}
<p class="type-label border-t border-border/40 px-4 pt-3 pb-1 text-muted">Derived</p>
<dl class="divide-y divide-border/25">
@for (row of readouts().derived; track row.label) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="type-label text-muted">{{ row.label }}</dt>
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
</div>
}
</dl>
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
`
})
export class ReadoutSectionsComponent {
readonly readouts = input.required<BodyReadouts>();
}
@@ -79,9 +79,11 @@ export class StarLabelOverlay {
private addLabel(point: LabeledPoint): void { private addLabel(point: LabeledPoint): void {
const element = document.createElement('div'); const element = document.createElement('div');
// Tailwind utility classes assigned directly since this element lives outside Angular's // Classes assigned directly since this element lives outside Angular's view encapsulation
// view encapsulation (see the class comment above) rather than through a component template. // (see the class comment above). The offset and leader line live in `.map-label` itself:
element.className = 'map-label translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body'; // CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
// would be overwritten — the margin is the offset it cannot touch.
element.className = 'map-label whitespace-nowrap font-body';
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'map-label-name'; name.className = 'map-label-name';
@@ -96,6 +98,11 @@ export class StarLabelOverlay {
} }
const object = new CSS2DObject(element); const object = new CSS2DObject(element);
// Anchor the label's left edge at the point, vertically centred. The default center of
// (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the
// star — under which `.map-label`'s margin offset only nudges the centred box sideways and
// the leader line points at empty space half the label's width from the star.
object.center.set(0, 0.5);
object.position.set(point.x, point.y, point.z); object.position.set(point.x, point.y, point.z);
this.scene.add(object); this.scene.add(object);
this.labelObjects.set(point.id, object); this.labelObjects.set(point.id, object);
@@ -94,11 +94,11 @@ describe('StarmapHudComponent', () => {
fixture.componentRef.setInput('title', 'Sol'); fixture.componentRef.setInput('title', 'Sol');
fixture.detectChanges(); fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol'); expect((fixture.nativeElement as HTMLElement).querySelector('[data-testid="hud-banner"]')?.textContent?.trim()).toBe('Sol');
}); });
it('shows no banner when the view is holding nothing', () => { it('shows no banner when the view is holding nothing', () => {
// An empty nameplate is worse than none: it reads as a selection that failed to resolve. // An empty nameplate is worse than none: it reads as a selection that failed to resolve.
expect(render('galaxy').querySelector('.hud-banner')).toBeNull(); expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull();
}); });
}); });
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ViewLevel } from '../../shared/state/navigation.store'; import { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
export interface HudReadout { export interface HudReadout {
readonly label: string; readonly label: string;
@@ -45,29 +46,26 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@Component({ @Component({
selector: 'app-starmap-hud', selector: 'app-starmap-hud',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReticleIconComponent],
host: { class: 'pointer-events-none absolute inset-0 block select-none' }, host: { class: 'pointer-events-none absolute inset-0 block select-none' },
template: ` template: `
<div class="hud-frame absolute inset-0"></div>
<div class="hud-vignette absolute inset-0"></div> <div class="hud-vignette absolute inset-0"></div>
@if (showReticle()) { @if (showReticle()) {
<!-- A hexagon rather than a square bracket: the shape reads as a sensor lock on a body, <!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
and stays distinct from the rectangular panel chrome everywhere else on screen. --> for the whole instrument, whether it is holding a query or a body. -->
<svg class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" viewBox="0 0 56 56" fill="none" aria-hidden="true"> <app-reticle-icon class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" [strokeWidth]="1" />
<polygon points="28,4 48,16 48,40 28,52 8,40 8,16" stroke="currentColor" stroke-width="1" />
<path d="M28 22v-6M28 40v-6M22 28h-6M40 28h-6" stroke="currentColor" stroke-width="1" opacity="0.8" />
</svg>
} }
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line <!-- 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. --> across the top of the display, clear of the search field above them. -->
<nav aria-label="Map scale" class="pointer-events-auto absolute top-16 left-6 flex items-stretch gap-px"> <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">
@for (step of ladder(); track step.level) { @for (step of ladder(); track step.level) {
@if (step.reachable) { @if (step.reachable) {
<button <button
type="button" type="button"
(click)="levelSelected.emit(step.level)" (click)="levelSelected.emit(step.level)"
class="hud-tab border-y border-border/70 bg-panel/70 px-4 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase backdrop-blur-sm transition-colors hover:bg-accent/15 hover:text-accent focus:bg-accent/15 focus:text-accent focus:outline-none" class="type-eyebrow px-4 py-1.5 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
> >
{{ step.label }} {{ step.label }}
</button> </button>
@@ -75,8 +73,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
<span <span
[attr.aria-current]="step.state === 'current' ? 'step' : null" [attr.aria-current]="step.state === 'current' ? 'step' : null"
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null" [attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
class="hud-tab border-y px-4 py-1.5 font-display text-[10px] tracking-[0.22em] uppercase backdrop-blur-sm" class="type-eyebrow px-4 py-1.5"
[class]="step.state === 'current' ? 'border-accent/70 bg-accent/20 text-accent' : 'border-border/40 bg-panel/40 text-border'" [class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
>{{ step.label }}</span >{{ step.label }}</span
> >
} }
@@ -84,21 +82,21 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
</nav> </nav>
@if (title()) { @if (title()) {
<div class="absolute top-16 left-1/2 -translate-x-1/2"> <!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
<div class="hud-banner flex items-center gap-2.5 border-b border-accent/60 bg-panel/75 px-6 py-1.5 backdrop-blur-sm"> long star name runs into the scale rail on its left and under the object card on its
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 12 12" fill="none" aria-hidden="true"> right — all three share the top-16 line. -->
<polygon points="6,1 10.5,3.5 10.5,8.5 6,11 1.5,8.5 1.5,3.5" stroke="currentColor" stroke-width="1" /> <div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block">
<circle cx="6" cy="6" r="1.4" fill="currentColor" /> <div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
</svg> <app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
<span class="font-display text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span> <span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
</div> </div>
</div> </div>
} }
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4"> <div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
<div class="hud-panel max-w-lg px-4 py-3"> <div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p> <p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p> <p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) { @if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p> <p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
} }
@@ -106,20 +104,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1"> <dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) { @for (readout of readouts(); track readout.label) {
<div> <div>
<dt class="text-[10px] tracking-[0.18em] text-muted uppercase">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt> <dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
<dd class="text-sm text-text tabular-nums">{{ readout.value }}</dd> <dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
</div> </div>
} }
</dl> </dl>
} }
@if (note() || hasDerived()) { @if (note() || hasDerived()) {
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p> <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>
<div class="hud-panel px-4 py-3 text-right"> <div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">Range</p> <p class="type-label text-muted">Range</p>
<p class="mt-1 font-display text-lg text-accent tabular-nums">{{ range() }}</p> <p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
</div> </div>
</div> </div>
` `
@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { bodyReadouts } from '../body-detail/body-readouts'; import { bodyReadouts } from '../body-detail/body-readouts';
import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
/** /**
* The card shown for a body picked in the system view, without leaving it. * The card shown for a body picked in the system view, without leaving it.
@@ -11,31 +13,34 @@ import { bodyReadouts } from '../body-detail/body-readouts';
* shows the same numbers over the live view, and keeps the route as the deliberate step for the * shows the same numbers over the live view, and keeps the route as the deliberate step for the
* full 3D inspection. * full 3D inspection.
* *
* The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only. * The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the detail
* page. Presentational only.
*/ */
@Component({ @Component({
selector: 'app-system-object-card', selector: 'app-system-object-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChevronIconComponent, ReadoutSectionsComponent],
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
// the scene rather than against whatever box the inline default would have left it in — which // the scene rather than against whatever box the inline default would have left it in — which
// put the card off the bottom-left corner of the viewport entirely. // put the card off the bottom-left corner of the viewport entirely.
host: { class: 'pointer-events-none absolute inset-0 block' }, host: { class: 'pointer-events-none absolute inset-0 block' },
template: ` template: `
<!-- Positioning and panel styling stay on separate elements. The hud-panel rule sets <!-- Positioning and panel styling stay on separate elements, so the panel's own layout
position: relative for its own inset border, which silently overrides an absolute on the never fights the absolute placement. Same organism as the detail page's info panel:
same element and turns top/right into offsets from wherever the box already sat. --> 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-16 right-6 w-80 max-w-[calc(100%-3rem)]">
<div data-testid="object-card" class="hud-panel px-4 py-3 font-body"> <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"> <div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<div class="min-w-0"> <header class="min-w-0">
<p class="truncate font-display text-lg tracking-[0.04em] text-text">{{ body().name }}</p> <p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
<p class="mt-0.5 font-display text-[10px] tracking-[0.22em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</div> </header>
<button <button
type="button" type="button"
(click)="dismissed.emit()" (click)="dismissed.emit()"
aria-label="Close" aria-label="Close"
class="-mt-1 -mr-1 shrink-0 rounded-sm p-1 text-muted transition-colors hover:text-accent focus:text-accent focus:outline-none" class="-mt-2 -mr-2 shrink-0 p-1 text-muted transition-colors hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-accent"
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M3 3l8 8M11 3l-8 8" /> <path d="M3 3l8 8M11 3l-8 8" />
@@ -43,35 +48,15 @@ import { bodyReadouts } from '../body-detail/body-readouts';
</button> </button>
</div> </div>
@if (readouts().measured.length) { <app-readout-sections [readouts]="readouts()" />
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
@for (row of readouts().measured; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
}
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
@for (row of readouts().derived; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
<button <button
type="button" type="button"
(click)="openRequested.emit()" (click)="openRequested.emit()"
class="mt-3 flex w-full items-center justify-center gap-1.5 border border-border/70 bg-panel/60 px-3 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:border-accent focus:text-accent focus:outline-none" class="type-label flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 py-2 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
> >
Full view Full view
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <app-chevron-icon class="h-3 w-3" direction="right" />
<path d="M9 6l6 6-6 6" />
</svg>
</button> </button>
</div> </div>
</div> </div>
+45 -13
View File
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking'; import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
const MAX_RESULTS = 8; const MAX_RESULTS = 8;
@@ -22,37 +23,55 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
*/ */
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
imports: [ReticleIconComponent],
template: ` template: `
<div class="fixed top-4 left-1/2 z-20 w-[22rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body"> <div class="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<div class="relative"> <!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
<circle cx="11" cy="11" r="7" /> the star field. -->
<path d="M21 21l-4.3-4.3" /> <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">
</svg> <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 <input
type="text" type="text"
placeholder="Search stars, planets, exoplanets…" placeholder="Search stars, planets, exoplanets…"
[value]="query()" [value]="query()"
(input)="onInput($event)" (input)="onInput($event)"
(keydown.escape)="clear()" (keydown.escape)="clear()"
class="w-full rounded-md border border-border bg-panel/85 py-2 pl-9 pr-3 text-sm text-text backdrop-blur-md transition-colors placeholder:text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/50" class="w-full bg-transparent py-2.5 pr-3 pl-10 text-sm tracking-[0.02em] text-text caret-accent placeholder:text-muted focus:outline-none"
/> />
</div> </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. -->
@if (indexReady() && hasQuery()) {
<div class="hud-acquire hud-surface mt-2">
@if (results().length) { @if (results().length) {
<ul data-testid="search-results" class="mt-1 divide-y divide-border/40 overflow-hidden rounded-md border border-border bg-panel/90 backdrop-blur-md"> <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>
@if (matchTotal() > results().length) {
<span> · top {{ results().length }}</span>
}
</p>
<ul data-testid="search-results" class="divide-y divide-border/25">
@for (result of results(); track result.bodyId ?? result.starId) { @for (result of results(); track result.bodyId ?? result.starId) {
<li> <li>
<button <button
type="button" type="button"
(click)="select(result)" (click)="select(result)"
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-accent/10 focus:bg-accent/10 focus:outline-none" class="flex w-full items-baseline gap-3 border-l border-transparent px-3 py-2 text-left transition-colors hover:border-l-accent hover:bg-accent/8 focus-visible:border-l-accent focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
> >
<span class="text-sm text-text">{{ result.name }}</span> <span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span> <span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button> </button>
</li> </li>
} }
</ul> </ul>
} @else {
<p class="type-label px-3 py-2.5 text-center text-muted">Nothing in the catalog matches <span class="normal-case text-text">“{{ query().trim() }}”</span></p>
}
</div>
} }
</div> </div>
` `
@@ -61,15 +80,27 @@ export class SearchComponent {
readonly query = signal(''); readonly query = signal('');
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]); private readonly index = signal<IndexedSearchEntry[]>([]);
/** False until the catalogues have loaded — and forever if they fail, which beats a false
* "no matches" for bodies that exist but could not be fetched. */
readonly indexReady = signal(false);
readonly results = computed(() => { /** True once the query is long enough to have been searched — so "no matches" is only ever
* reported about a search that actually ran, never about a half-typed word. */
readonly hasQuery = computed(() => this.query().trim().length >= MIN_QUERY_LENGTH);
/** Every match, ranked. Kept separate from the display slice so the "Matches" count can
* report the real total rather than the page size. */
private readonly matches = computed(() => {
const query = this.query().trim(); const query = this.query().trim();
if (query.length < MIN_QUERY_LENGTH) { if (query.length < MIN_QUERY_LENGTH) {
return []; return [];
} }
return rankSearchResults(this.index(), query, MAX_RESULTS); return rankSearchResults(this.index(), query, Number.POSITIVE_INFINITY);
}); });
readonly matchTotal = computed(() => this.matches().length);
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
constructor( constructor(
private readonly dataLoader: DataLoaderService, private readonly dataLoader: DataLoaderService,
private readonly navigationStore: NavigationStore, private readonly navigationStore: NavigationStore,
@@ -115,6 +146,7 @@ export class SearchComponent {
]; ];
this.index.set(buildSearchIndex(entries)); this.index.set(buildSearchIndex(entries));
this.indexReady.set(true);
} catch (error) { } catch (error) {
console.error('Failed to build the search index.', error); console.error('Failed to build the search index.', error);
} }
@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* The route-rail chevron — "back to" when pointing left, "on to" when pointing right. One
* geometry for every rail button, where each previously inlined its own copy of the svg.
* Size and colour come from the classes on the host; the svg fills it.
*/
@Component({
selector: 'app-chevron-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block', 'aria-hidden': 'true' },
template: `
<svg class="h-full w-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path [attr.d]="direction() === 'left' ? 'M15 6l-6 6 6 6' : 'M9 6l6 6-6 6'" />
</svg>
`
})
export class ChevronIconComponent {
readonly direction = input<'left' | 'right'>('left');
}
@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* The circle-and-ticks lock mark the instrument brands itself with. The search field, the
* nameplate and the viewport reticle all render this one geometry — it used to be drawn three
* times, once with hand-rescaled coordinates, and the copies could drift apart.
*
* Size and colour come from the classes on the host (`h-4 w-4 text-accent/70` etc.); the svg
* fills it. `vector-effect: non-scaling-stroke` keeps the stroke a screen-pixel width however
* far the 24-unit viewBox is scaled, so `strokeWidth` means the same at every size.
*/
@Component({
selector: 'app-reticle-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block', 'aria-hidden': 'true' },
template: `
<svg class="h-full w-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" [attr.stroke-width]="strokeWidth()" stroke-linecap="round">
<circle cx="12" cy="12" r="5" vector-effect="non-scaling-stroke" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" vector-effect="non-scaling-stroke" />
</svg>
`
})
export class ReticleIconComponent {
readonly strokeWidth = input(1.5);
}
+2 -2
View File
@@ -9,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body."> <meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body.">
<!-- Matches --color-void, so the browser chrome does not flash white around a black sky. --> <!-- Matches --color-void, so the browser chrome does not flash white around a black sky. -->
<meta name="theme-color" content="#05070d"> <meta name="theme-color" content="#04060c">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so <!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so
@@ -24,7 +24,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+109 -62
View File
@@ -1,19 +1,20 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
@import 'tailwindcss'; @import 'tailwindcss';
/* Design tokens for the sci-fi "HUD" look shared across the app. `--color-*`/`--font-*` names /* Design tokens for the "observatory instrument" HUD shared across the app. `--color-*`/`--font-*`
* automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`). */ * names automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`).
*
* One family only: every string in this UI is a measurement, an identifier, or a catalog label, so
* the readout face carries the whole hierarchy through weight, size, and tracking. */
@theme { @theme {
--font-display: 'Orbitron', 'Segoe UI', sans-serif; --font-body: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace;
--font-body: 'JetBrains Mono', 'Segoe UI', monospace;
--color-void: #05070d; --color-void: #04060c;
--color-panel: #0b121f; --color-panel: #070d16;
--color-accent: #4dd7ff; --color-accent: #4dd7ff;
--color-accent-dim: #1c4f66; --color-text: #e3ecf7;
--color-text: #dbe6f3; --color-muted: #90a6bd;
--color-muted: #7f93ab; --color-border: #24455a;
--color-border: #2a4a5c;
} }
html, html,
@@ -26,48 +27,85 @@ body {
font-family: var(--font-body); font-family: var(--font-body);
} }
/* --- HUD chrome ------------------------------------------------------------------------- */ /* The panel surface every HUD element shares: a hairline border over a near-opaque fill. One
* definition, because spelling the recipe out per call site is how it forked into /85 and /92
/* Angled top-left/bottom-right corners, a thin lit edge and a blurred dark fill: the panel * variants within a single change. Deliberately no backdrop blur: the canvas underneath is
* shape the whole heads-up display is built from. Written here rather than as utilities * redrawn every frame by the render loop, so a blur here is re-sampled continuously — a
* because `clip-path` and the layered border need to travel together. */ * standing GPU cost for an effect a 92%-opaque fill mostly hides anyway. */
.hud-panel { @utility hud-surface {
position: relative; border: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
background: color-mix(in srgb, var(--color-panel) 78%, transparent); background-color: color-mix(in oklab, var(--color-panel) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent);
backdrop-filter: blur(8px);
clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
} }
/* The lit inner hairline that reads as a bevel on the two square corners. */ /* The two label voices of the single-face type system, named so call sites cannot drift a few
.hud-panel::after { * thousandths of an em apart. `type-label` is the working voice — section headings, readout
content: ''; * labels, buttons; `type-eyebrow` is the wider-tracked identity line above a title. */
position: absolute; @utility type-label {
inset: 3px; font-size: 10px;
border-top: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); letter-spacing: 0.16em;
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); text-transform: uppercase;
pointer-events: none;
} }
/* Corner brackets around the whole viewport, drawn as four gradients so the element stays a @utility type-eyebrow {
* single node with no children. */ font-size: 10px;
.hud-frame { letter-spacing: 0.18em;
pointer-events: none; text-transform: uppercase;
--bracket: color-mix(in srgb, var(--color-accent) 45%, transparent); }
--arm-long: 76px;
--arm-thin: 1px; /* The HUD's signature: four corner tick brackets cut into a panel's hairline frame. Drawn as
* eight background gradients so any panel (or the viewport frame) picks them up without the
* eight extra DOM nodes the same mark would otherwise cost. Override `--hud-tick` per element
* (e.g. `[--hud-tick:1.75rem]`) to scale the marks with the surface they frame. */
@utility hud-brackets {
--hud-tick: 0.625rem;
--hud-mark: color-mix(in oklab, var(--color-accent) 65%, transparent);
background-image: background-image:
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)); linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0);
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: background-size:
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--hud-tick) 1px,
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long); 1px var(--hud-tick),
var(--hud-tick) 1px,
1px var(--hud-tick),
var(--hud-tick) 1px,
1px var(--hud-tick),
var(--hud-tick) 1px,
1px var(--hud-tick);
background-position: background-position:
18px 18px, 18px 18px, right 18px top 18px, right 18px top 18px, 18px bottom 18px, 18px bottom 18px, right 18px bottom 18px, left top,
right 18px bottom 18px; left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
}
/* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an
* exponential ease-out the way a readout fills once an instrument locks onto a target. */
@utility hud-acquire {
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes hud-acquire {
from {
opacity: 0;
clip-path: inset(0 0 100% 0);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
.hud-acquire {
animation-duration: 1ms;
}
} }
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */ /* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
@@ -76,22 +114,9 @@ body {
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%); background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
} }
/* A chamfered tab: the angled-corner shape the scale ladder and the selection banner share.
* The cut is on the leading and trailing edges rather than the panel's diagonal corners, which
* is what makes a row of them read as tabs on a rail instead of a row of cards. */
.hud-tab {
clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
}
/* The selected-object banner: symmetric, flaring outward toward its lit bottom edge, so it
* reads as a nameplate hanging off the top of the display rather than another tab. */
.hud-banner {
clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 100%, 0 100%);
}
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside /* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility * Angular's view encapsulation as plain DOM nodes, so they're styled here rather than through
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */ * a scoped component stylesheet. */
.star-label-layer { .star-label-layer {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -100,17 +125,39 @@ body {
/* Two-line map label: what the thing is called, then what it is. The type line is deliberately /* Two-line map label: what the thing is called, then what it is. The type line is deliberately
* much quieter than the name — it should be readable when looked at and invisible when not, * much quieter than the name — it should be readable when looked at and invisible when not,
* because on a crowded view it is printed as many times as there are labels. */ * because on a crowded view it is printed as many times as there are labels.
*
* Offset from the anchor with a margin, not a translate: `CSS2DRenderer` rewrites the inline
* `transform` every frame, and the margin is the one offset it can never overwrite. The
* hairline `::before` is the leader line back to the point the label names. */
.map-label { .map-label {
position: relative;
margin-left: 0.875rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
line-height: 1.15; line-height: 1.15;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); text-shadow:
0 0 6px var(--color-void),
0 0 3px var(--color-void);
}
/* Geometry assumes the label's left edge sits at the star — StarLabelOverlay sets the
* CSS2DObject's center to (0, 0.5) to make that true. Under the default (0.5, 0.5) the
* renderer keeps the box centred on the point and this line floats in empty space. */
.map-label::before {
content: '';
position: absolute;
top: 50%;
left: -0.75rem;
height: 1px;
width: 0.625rem;
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
} }
.map-label-name { .map-label-name {
font-size: 11px; font-size: 11px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-accent); color: var(--color-accent);
} }
@@ -118,5 +165,5 @@ body {
font-size: 8px; font-size: 8px;
letter-spacing: 0.3em; letter-spacing: 0.3em;
text-transform: uppercase; text-transform: uppercase;
color: color-mix(in srgb, var(--color-accent) 55%, transparent); color: color-mix(in oklab, var(--color-accent) 70%, transparent);
} }