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
155 lines
6.7 KiB
TypeScript
155 lines
6.7 KiB
TypeScript
import { Component, computed, signal } from '@angular/core';
|
|
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;
|
|
const MIN_QUERY_LENGTH = 2;
|
|
|
|
const KIND_LABELS: Record<SearchResultKind, string> = {
|
|
star: 'Star',
|
|
body: 'Body',
|
|
exoplanet: 'Exoplanet'
|
|
};
|
|
|
|
/**
|
|
* Name search across stars, solar-system bodies, and exoplanets. Selecting a star result
|
|
* jumps straight into that system's view; selecting a body/exoplanet result navigates to its
|
|
* detail route — both go through `NavigationStore`/the router so behavior matches an
|
|
* in-scene click, per the "consistent state across views" requirement.
|
|
*/
|
|
@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">
|
|
<!-- 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…"
|
|
[value]="query()"
|
|
(input)="onInput($event)"
|
|
(keydown.escape)="clear()"
|
|
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>
|
|
|
|
<!-- 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>
|
|
}
|
|
</div>
|
|
`
|
|
})
|
|
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);
|
|
|
|
/** 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, 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,
|
|
private readonly router: Router
|
|
) {
|
|
void this.buildIndex();
|
|
}
|
|
|
|
onInput(event: Event): void {
|
|
this.query.set((event.target as HTMLInputElement).value);
|
|
}
|
|
|
|
clear(): void {
|
|
this.query.set('');
|
|
}
|
|
|
|
kindLabel(kind: SearchResultKind): string {
|
|
return KIND_LABELS[kind];
|
|
}
|
|
|
|
select(result: SearchEntry): void {
|
|
this.clear();
|
|
if (result.kind === 'star' && result.starId !== undefined) {
|
|
this.navigationStore.selectStar(result.starId);
|
|
void this.router.navigate(['/']);
|
|
} else if (result.bodyId) {
|
|
void this.router.navigate(['/body', result.bodyId]);
|
|
}
|
|
}
|
|
|
|
private async buildIndex(): Promise<void> {
|
|
try {
|
|
const [{ stars }, bodies, exoplanets] = await Promise.all([
|
|
this.dataLoader.loadStars(),
|
|
this.dataLoader.loadBodies(),
|
|
this.dataLoader.loadExoplanets()
|
|
]);
|
|
|
|
const entries: SearchEntry[] = [
|
|
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: star.spectralType, starId: star.id })),
|
|
...bodies.map((body): SearchEntry => ({ kind: 'body', name: body.name, subtitle: body.kind, bodyId: body.id })),
|
|
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
|
|
];
|
|
|
|
this.index.set(buildSearchIndex(entries));
|
|
this.indexReady.set(true);
|
|
} catch (error) {
|
|
console.error('Failed to build the search index.', error);
|
|
}
|
|
}
|
|
}
|