import { Component, computed, ElementRef, output, signal, viewChild } 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 = { 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: `
@if (indexReady() && hasQuery()) {
@if (results().length) {

Matches {{ matchTotal() }} @if (matchTotal() > results().length) { · top {{ results().length }} }

    @for (result of results(); track result.bodyId ?? result.starId) {
  • }
} @else {

Nothing in the catalog matches “{{ query().trim() }}”

}
}
` }) export class SearchComponent { readonly query = signal(''); /** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand * the view back to the readout, since the thing to look at is now the scene, not the box. */ readonly picked = output(); private readonly field = viewChild.required>('field'); /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ private readonly index = signal([]); /** 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(''); } focus(): void { this.field().nativeElement.focus(); } 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]); } this.picked.emit(); } private async buildIndex(): Promise { 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); } } }