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>
+55 -23
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>
@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"> <!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
@for (result of results(); track result.bodyId ?? result.starId) { the rows instead of remounting the panel and replaying its acquire wipe per
<li> keystroke. Gated on the index being ready: "no matches" may only ever describe a
<button search that actually ran against the loaded catalogues. -->
type="button" @if (indexReady() && hasQuery()) {
(click)="select(result)" <div class="hud-acquire hud-surface mt-2">
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" @if (results().length) {
> <p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
<span class="text-sm text-text">{{ result.name }}</span> Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span> @if (matchTotal() > results().length) {
</button> <span> · top {{ results().length }}</span>
</li> }
</p>
<ul data-testid="search-results" class="divide-y divide-border/25">
@for (result of results(); track result.bodyId ?? result.starId) {
<li>
<button
type="button"
(click)="select(result)"
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="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
<span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button>
</li>
}
</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>
} }
</ul> </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);
} }