Fix what the review confirmed: anchoring, overlap, honesty, one voice
Fourteen findings from an adversarial review pass, applied together because most share two roots — geometry written against assumptions the renderer does not hold, and a design language spelled out longhand until it drifted. Correctness. The leader line pointed at empty space: CSS2DObject centres a label on its anchor by default, and margin only nudges the centred box — the overlay now anchors the left edge (center 0, 0.5) so the hairline meets the star. The info panel and not-found panel returned to top-4 at sm, under a search field that is 26rem wide and centred, covering the back button on every viewport from 640 to 1088px; they now wait for xl. The search empty state asserted "nothing matches" while the catalogues were still loading — and forever if they failed — so it now waits for the index. "Matches 8" was the page size wearing the costume of a count; the header now reports the real total, with the cap stated when it bites. The nameplate un-hides at lg instead of sm, clear of the rail and the object card. theme-color matches the void again. Accessibility. Tabbing to search changed one hairline's hue; the wrapper now carries the old ring as a visible focus indicator. Structure. hud-surface names the panel recipe that had been inlined at seven sites and had already forked into /85 and /92; the backdrop blur it carried is gone from every near-opaque panel — the canvas beneath redraws every frame, so each blur was re-sampled continuously for an effect the fill hid — and survives only as blur-sm on the genuinely translucent search shell. type-label and type-eyebrow name the two label voices, retiring 0.14em and the stray hover:bg-accent/10. The Measured/Derived rows both cards pasted twice each live once in ReadoutSectionsComponent; the reticle and chevron each draw from a single geometry, the reticle's stroke held in screen pixels so one mark serves every size. The acquire wipe plays once per search, not once per keystroke, because both outcomes now share one panel. hud-banner, which styled nothing since the rule was deleted, is a data-testid — the convention its own file already used. 527 unit tests, 6/6 end-to-end, production build, and a driven screenshot: every label now hangs off its star with the hairline touching the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -13,6 +13,7 @@ import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
import { buildBodyViewModel } from './body-view-model';
|
||||
import { InfoPanelComponent } from './info-panel.component';
|
||||
@@ -36,23 +37,21 @@ const GLOW_SCALE = 2.6;
|
||||
@Component({
|
||||
selector: 'app-body-detail-scene',
|
||||
providers: [EngineService],
|
||||
imports: [InfoPanelComponent, RouterLink],
|
||||
imports: [ChevronIconComponent, InfoPanelComponent, RouterLink],
|
||||
template: `
|
||||
<div class="relative h-full w-full">
|
||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||
@if (viewModel()) {
|
||||
<app-info-panel [body]="viewModel()!" />
|
||||
} @else if (notFound()) {
|
||||
<div class="hud-brackets hud-acquire absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] border border-border/60 bg-panel/92 p-4 font-body text-text backdrop-blur-md sm:top-4">
|
||||
<p class="text-[10px] tracking-[0.18em] text-accent uppercase">No record</p>
|
||||
<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="type-eyebrow text-accent">No record</p>
|
||||
<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="mt-4 inline-flex items-center gap-2 border border-border/60 px-3 py-1.5 text-[10px] tracking-[0.16em] text-muted uppercase transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
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"
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
<app-chevron-icon class="h-3 w-3" direction="left" />
|
||||
Back to the galaxy
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +1,43 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
import { bodyReadouts } from './body-readouts';
|
||||
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
|
||||
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
||||
* `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
|
||||
* cannot read differently in the two places it can be inspected.
|
||||
* The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the system
|
||||
* view's object card so the same body cannot read differently in the two places it can be
|
||||
* inspected.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-info-panel',
|
||||
imports: [ChevronIconComponent, ReadoutSectionsComponent],
|
||||
template: `
|
||||
<!-- Sits below the search field on narrow viewports, beside it from sm up, so the two
|
||||
top-anchored overlays never stack on top of each other on a phone. -->
|
||||
<div class="hud-brackets hud-acquire absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] border border-border/60 bg-panel/92 font-body text-text backdrop-blur-md sm:top-4">
|
||||
<!-- 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
|
||||
type="button"
|
||||
(click)="goBack()"
|
||||
class="flex w-full items-center gap-2 border-b border-border/40 px-4 py-2 text-[10px] tracking-[0.16em] text-muted uppercase 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"
|
||||
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 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
<app-chevron-icon class="h-3 w-3" direction="left" />
|
||||
System
|
||||
</button>
|
||||
|
||||
<header class="px-4 pt-4 pb-3">
|
||||
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
||||
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
</header>
|
||||
|
||||
<!-- Readout rows: label left, figure right. Tabular figures keep the decimal points
|
||||
aligned down the column. -->
|
||||
@if (readouts().measured.length) {
|
||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
||||
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
|
||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ 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>
|
||||
<app-readout-sections [readouts]="readouts()" />
|
||||
</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>();
|
||||
}
|
||||
@@ -98,6 +98,11 @@ export class StarLabelOverlay {
|
||||
}
|
||||
|
||||
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);
|
||||
this.scene.add(object);
|
||||
this.labelObjects.set(point.id, object);
|
||||
|
||||
@@ -94,11 +94,11 @@ describe('StarmapHudComponent', () => {
|
||||
fixture.componentRef.setInput('title', 'Sol');
|
||||
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', () => {
|
||||
// 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 { ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||
|
||||
export interface HudReadout {
|
||||
readonly label: string;
|
||||
@@ -45,6 +46,7 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
@Component({
|
||||
selector: 'app-starmap-hud',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReticleIconComponent],
|
||||
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
||||
template: `
|
||||
<div class="hud-vignette absolute inset-0"></div>
|
||||
@@ -52,21 +54,18 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
@if (showReticle()) {
|
||||
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
|
||||
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" stroke="currentColor" stroke-width="1" aria-hidden="true">
|
||||
<circle cx="28" cy="28" r="15" />
|
||||
<path d="M28 6v7M28 43v7M6 28h7M43 28h7" />
|
||||
</svg>
|
||||
<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" />
|
||||
}
|
||||
|
||||
<!-- 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. -->
|
||||
<nav aria-label="Map scale" class="hud-brackets pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40 border border-border/60 bg-panel/85 backdrop-blur-md">
|
||||
<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) {
|
||||
@if (step.reachable) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="levelSelected.emit(step.level)"
|
||||
class="px-4 py-1.5 text-[10px] tracking-[0.18em] text-muted uppercase transition-colors hover:bg-accent/10 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
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 }}
|
||||
</button>
|
||||
@@ -74,7 +73,7 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
<span
|
||||
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
||||
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
||||
class="px-4 py-1.5 text-[10px] tracking-[0.18em] uppercase"
|
||||
class="type-eyebrow px-4 py-1.5"
|
||||
[class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
|
||||
>{{ step.label }}</span
|
||||
>
|
||||
@@ -83,22 +82,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
</nav>
|
||||
|
||||
@if (title()) {
|
||||
<!-- Hidden on phones: the readout panel names the same thing, and at this width the
|
||||
nameplate would sit on top of the scale rail. -->
|
||||
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 sm:block">
|
||||
<div class="hud-banner hud-brackets hud-acquire flex items-center gap-2.5 border border-border/60 bg-panel/85 px-6 py-1.5 backdrop-blur-md">
|
||||
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</svg>
|
||||
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
|
||||
long star name runs into the scale rail on its left and under the object card on its
|
||||
right — all three share the top-16 line. -->
|
||||
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block">
|
||||
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
|
||||
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
|
||||
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="hud-brackets hud-acquire max-w-lg border border-border/60 bg-panel/92 px-4 py-3 backdrop-blur-md">
|
||||
<p class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ eyebrow() }}</p>
|
||||
<div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
|
||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||
@if (subtitle()) {
|
||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||
@@ -107,7 +104,7 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
@for (readout of readouts(); track readout.label) {
|
||||
<div>
|
||||
<dt class="text-[10px] tracking-[0.16em] 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="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
@@ -118,8 +115,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hud-brackets hud-acquire border border-border/60 bg-panel/92 px-4 py-3 text-right backdrop-blur-md">
|
||||
<p class="text-[10px] tracking-[0.16em] text-muted uppercase">Range</p>
|
||||
<div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
|
||||
<p class="type-label text-muted">Range</p>
|
||||
<p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
|
||||
|
||||
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||
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.
|
||||
@@ -11,11 +13,13 @@ 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
|
||||
* 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({
|
||||
selector: 'app-system-object-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ChevronIconComponent, ReadoutSectionsComponent],
|
||||
// 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
|
||||
// put the card off the bottom-left corner of the viewport entirely.
|
||||
@@ -23,14 +27,14 @@ import { bodyReadouts } from '../body-detail/body-readouts';
|
||||
template: `
|
||||
<!-- Positioning and panel styling stay on separate elements, so the panel's own layout
|
||||
never fights the absolute placement. Same organism as the detail page's info panel:
|
||||
header, full-bleed readout rows, provenance, 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. -->
|
||||
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 data-testid="object-card" class="hud-brackets hud-acquire border border-border/60 bg-panel/92 font-body text-text backdrop-blur-md">
|
||||
<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 px-4 pt-4 pb-3">
|
||||
<header class="min-w-0">
|
||||
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
||||
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
</header>
|
||||
<button
|
||||
type="button"
|
||||
@@ -44,39 +48,15 @@ import { bodyReadouts } from '../body-detail/body-readouts';
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (readouts().measured.length) {
|
||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
||||
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
|
||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ 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>
|
||||
<app-readout-sections [readouts]="readouts()" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="openRequested.emit()"
|
||||
class="flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 py-2 text-[10px] tracking-[0.16em] text-muted uppercase 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"
|
||||
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
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
<app-chevron-icon class="h-3 w-3" direction="right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
@@ -22,13 +23,14 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
imports: [ReticleIconComponent],
|
||||
template: `
|
||||
<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="hud-brackets relative border border-border/60 bg-panel/85 backdrop-blur-md transition-colors focus-within:border-accent/70">
|
||||
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</svg>
|
||||
<!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
|
||||
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
|
||||
the star field. -->
|
||||
<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">
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Search stars, planets, exoplanets…"
|
||||
@@ -39,26 +41,37 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (results().length) {
|
||||
<div class="hud-acquire mt-2 border border-border/60 bg-panel/92 backdrop-blur-md">
|
||||
<p class="border-b border-border/40 px-3 py-1.5 text-[10px] tracking-[0.16em] text-muted uppercase">Matches <span class="text-accent tabular-nums">{{ results().length }}</span></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="max-w-[45%] shrink-0 truncate text-[10px] tracking-[0.14em] text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
|
||||
the rows instead of remounting the panel and replaying its acquire wipe per
|
||||
keystroke. Gated on the index being ready: "no matches" may only ever describe a
|
||||
search that actually ran against the loaded catalogues. -->
|
||||
@if (indexReady() && hasQuery()) {
|
||||
<div class="hud-acquire hud-surface mt-2">
|
||||
@if (results().length) {
|
||||
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
|
||||
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
|
||||
@if (matchTotal() > results().length) {
|
||||
<span> · top {{ results().length }}</span>
|
||||
}
|
||||
</p>
|
||||
<ul data-testid="search-results" class="divide-y divide-border/25">
|
||||
@for (result of results(); track result.bodyId ?? result.starId) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
} @else if (hasQuery()) {
|
||||
<p class="hud-acquire mt-2 border border-border/60 bg-panel/92 px-3 py-2.5 text-center text-[11px] tracking-[0.14em] text-muted uppercase backdrop-blur-md">Nothing in the catalog matches <span class="normal-case text-text">“{{ query().trim() }}”</span></p>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -67,19 +80,27 @@ export class SearchComponent {
|
||||
readonly query = signal('');
|
||||
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
|
||||
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);
|
||||
|
||||
/** 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);
|
||||
|
||||
readonly results = computed(() => {
|
||||
/** 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();
|
||||
if (query.length < MIN_QUERY_LENGTH) {
|
||||
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(
|
||||
private readonly dataLoader: DataLoaderService,
|
||||
private readonly navigationStore: NavigationStore,
|
||||
@@ -125,6 +146,7 @@ export class SearchComponent {
|
||||
];
|
||||
|
||||
this.index.set(buildSearchIndex(entries));
|
||||
this.indexReady.set(true);
|
||||
} catch (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);
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<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.">
|
||||
<!-- 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 previews. These URLs must be absolute — a scraper resolves them against nothing — so
|
||||
|
||||
+29
-1
@@ -27,6 +27,31 @@ body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* 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
|
||||
* variants within a single change. Deliberately no backdrop blur: the canvas underneath is
|
||||
* redrawn every frame by the render loop, so a blur here is re-sampled continuously — a
|
||||
* standing GPU cost for an effect a 92%-opaque fill mostly hides anyway. */
|
||||
@utility hud-surface {
|
||||
border: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
background-color: color-mix(in oklab, var(--color-panel) 92%, transparent);
|
||||
}
|
||||
|
||||
/* The two label voices of the single-face type system, named so call sites cannot drift a few
|
||||
* thousandths of an em apart. `type-label` is the working voice — section headings, readout
|
||||
* labels, buttons; `type-eyebrow` is the wider-tracked identity line above a title. */
|
||||
@utility type-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@utility type-eyebrow {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 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
|
||||
@@ -116,10 +141,13 @@ body {
|
||||
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: 7px;
|
||||
top: 50%;
|
||||
left: -0.75rem;
|
||||
height: 1px;
|
||||
width: 0.625rem;
|
||||
|
||||
Reference in New Issue
Block a user