Restyle the HUD as an observatory instrument (#4)
Restyle the HUD as an observatory instrument
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user