diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 6867d19..b46708b 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -14,6 +14,7 @@ import { LinkBudget } from '../../shared/astro/jump-links'; import { HudDisplay } from '../hud/hud-dock.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { galacticNormal } from './grid-plane'; +import { catalogueCensus, positionsNote } from './star-readouts'; import { closestApproachAu, SUN_RADIUS_AU } from './system-framing'; import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar'; import { JumpLinkRenderer } from './jump-link-renderer'; @@ -38,7 +39,10 @@ const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: const ANTARES: StarRecord = { id: 80519, name: 'Antares', x: -58.54, y: -140.31, z: -75.57, magnitude: 1.06, magnitudeBand: 'V', spectralType: 'M1Ib + B2.5V', colorIndex: 1.865, colorSystem: 'B-V' }; const PROCYON_B: StarRecord = { id: 37279, name: 'Gl 280B', x: -1.08, y: 3.19, z: 0.34, magnitude: 10.7, magnitudeBand: 'V', spectralType: 'DA', colorIndex: null }; -const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA, ANTARES, PROCYON_B]; +// A host only the archive places, at the stand-in magnitude, with no type and no colour. +const LENS: StarRecord = { id: 1070000536, name: 'KMT-2016-BLG-1107L', x: 6651, y: 0, z: 0, magnitude: 15, spectralType: 'Unknown', colorIndex: null, source: 'exoplanet-archive' }; + +const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA, ANTARES, PROCYON_B, LENS]; const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z])); // Proxima's planet as the archive gives it, with its host's radius, temperature and luminosity. @@ -290,7 +294,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { const [focus] = refocus.mock.calls[0]; expect(focus.view).toBeDefined(); // The Sun has Earth and Proxima its b, so both are hosts; the others have nothing catalogued. - expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 1, 0, 0]); + expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 1, 0, 0, 0]); }); it('chooses again once the camera has turned half the margin, and not for less', async () => { @@ -951,6 +955,24 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(navigationStore.viewLevel()).toBe('galactic'); }); + it('names what the neighbourhood holds, and where the positions of the stars in it come from', async () => { + await advanceFrames(engine, 0.3); + const component = fixture.componentInstance as unknown as { hudSubtitle(): string; hudNote(): string }; + expect(component.hudSubtitle()).toBe(catalogueCensus(STARS)); + expect(component.hudNote()).toBe(positionsNote(STARS)); + expect(component.hudNote()).toContain('1 planet hosts only the NASA Exoplanet Archive places, from its distances'); + }); + + it('offers a star for a route by its classification, and a star with none by its name alone', () => { + const component = fixture.componentInstance as unknown as { starSearchIndex(): { entry: { starId?: number; subtitle: string } }[]; currentStarOption(): { subtitle: string } | null }; + expect(component.starSearchIndex().find(({ entry }) => entry.starId === LENS.id)?.entry.subtitle).toBe(''); + expect(component.starSearchIndex().find(({ entry }) => entry.starId === PROXIMA.id)?.entry.subtitle).toBe('M5V'); + navigationStore.selectStar(LENS.id); + expect(component.currentStarOption()?.subtitle).toBe(''); + navigationStore.selectStar(PROXIMA.id); + expect(component.currentStarOption()?.subtitle).toBe('M5V'); + }); + describe('each star at its own size and in its own colour', () => { type DrawnStar = { starMarkerGeometry: THREE.SphereGeometry; diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 2bd9fb5..d814e44 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -20,6 +20,7 @@ import { galacticCentrePositionPc, galacticToEquatorial, } from '../../shared/astro/galaxy'; +import { spectralClassification } from '../../shared/astro/spectral'; import { blackbodyColor } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService, SceneCamera } from '../../core/engine/engine.service'; @@ -84,7 +85,7 @@ import { JumpLinkRenderer } from './jump-link-renderer'; import { ReservedBox, ringPlacement } from './label-ring'; import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay'; import { SystemOrbitsRenderer } from './system-orbits-renderer'; -import { catalogueCensus, starReadouts, starSubtitle } from './star-readouts'; +import { catalogueCensus, positionsNote, starReadouts, starSubtitle } from './star-readouts'; /** Radius, in CSS pixels, below which a body in the system view is scaled up to be seen at all. */ const MIN_MARKER_PIXELS = 3; @@ -572,6 +573,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private stars: readonly StarRecord[] = []; /** The neighbourhood's subtitle: what the catalogue holds, by the catalogue describing it. */ private catalogueCensus = ''; + private positionsNote = ''; private starsById = new Map(); private bodies: readonly BodyRecord[] = []; private exoplanets: readonly ExoplanetRecord[] = []; @@ -721,6 +723,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { ]); this.stars = stars; this.catalogueCensus = catalogueCensus(stars); + this.positionsNote = positionsNote(stars); this.starsById = new Map(stars.map((star) => [star.id, star])); this.neighbourhood = new StarNeighbourhood(stars); this.routing = new RoutingClient(stars, positions, this.neighbourhood); @@ -730,7 +733,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { stars.map((star) => ({ kind: 'star' as const, name: star.name, - subtitle: star.spectralType, + subtitle: spectralClassification(star), starId: star.id, })), ), @@ -1848,9 +1851,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // The one thing the field itself cannot show: which of those points can be flown into. { label: 'Systems', value: `${this.enterableSystems}` }, ]); - this.hudNote.set( - 'Positions from measured parallaxes. Grid marks the galactic plane through the Sun.', - ); + this.hudNote.set(this.positionsNote); } /** Where the current press started, so a drag can be told apart from a click. */ @@ -1959,7 +1960,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { readonly currentStarOption = computed(() => { const starId = this.navigationStore.selectedStarId(); const star = starId === null ? undefined : this.starsById.get(starId); - return star ? { id: star.id, name: star.name, subtitle: star.spectralType } : null; + return star ? { id: star.id, name: star.name, subtitle: spectralClassification(star) } : null; }); onRouteQuery(query: string): void { diff --git a/src/app/features/galaxy-system/star-readouts.spec.ts b/src/app/features/galaxy-system/star-readouts.spec.ts index 88ff462..2f0f3c0 100644 --- a/src/app/features/galaxy-system/star-readouts.spec.ts +++ b/src/app/features/galaxy-system/star-readouts.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { StarRecord } from '../../shared/models/star.model'; -import { catalogueCensus, describingCatalogue, starReadouts, starSubtitle } from './star-readouts'; +import { catalogueCensus, describingCatalogue, positionsNote, starReadouts, starSubtitle } from './star-readouts'; /** Three kinds of star the catalogue holds, each as the decoder gives it back. */ const HYG_STAR: StarRecord = { @@ -94,6 +94,15 @@ describe('starReadouts', () => { }); }); +describe('positionsNote', () => { + it("says which positions are the archive's distances rather than parallaxes, and how many", () => { + expect(positionsNote([HYG_STAR, GAIA_STAR])).toBe('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.'); + expect(positionsNote([HYG_STAR, { ...GAIA_STAR, source: 'exoplanet-archive' }])).toBe( + 'Positions from measured parallaxes, and for the 1 planet hosts only the NASA Exoplanet Archive places, from its distances. Grid marks the galactic plane through the Sun.' + ); + }); +}); + describe('starSubtitle', () => { it("prints the catalogue's classification where it has one", () => { expect(starSubtitle(HYG_STAR)).toBe('Spectral type A0m'); diff --git a/src/app/features/galaxy-system/star-readouts.ts b/src/app/features/galaxy-system/star-readouts.ts index 2cc0aa3..b601a6f 100644 --- a/src/app/features/galaxy-system/star-readouts.ts +++ b/src/app/features/galaxy-system/star-readouts.ts @@ -1,4 +1,4 @@ -import { spectralTypeFromColor } from '../../shared/astro/spectral'; +import { spectralClassification } from '../../shared/astro/spectral'; import { formatDistance, formatLuminosity } from '../../shared/format/quantity'; import { StarRecord } from '../../shared/models/star.model'; import { StarSurface } from '../body-detail/body-view-model'; @@ -24,11 +24,8 @@ export function describingCatalogue(star: StarRecord): string { * estimate. Empty with neither, rather than the ETL's literal "Unknown". */ export function starSubtitle(star: StarRecord): string { - if (star.spectralType && star.spectralType !== 'Unknown') { - return `Spectral type ${star.spectralType}`; - } - const estimate = spectralTypeFromColor(star.colorIndex, star.colorSystem); - return estimate ? `Spectral type ~${estimate}, from colour` : ''; + const classification = spectralClassification(star); + return !classification ? '' : `Spectral type ${classification}${classification.startsWith('~') ? ', from colour' : ''}`; } /** @@ -66,6 +63,20 @@ function radiusReadout(radiusSolar: number, derived: boolean): HudReadout { : { label: 'Radius', value: `${figure} solar radii` }; } +/** + * Where the neighbourhood's positions come from: parallaxes, except for the stars only the + * Exoplanet Archive places, which sit at its own distances — a lensing model's for the + * microlensing hosts among them, OGLE-2005-BLG-390L's 6.6 kpc for one, with no parallax behind it. + */ +export function positionsNote(stars: readonly StarRecord[]): string { + const archive = stars.filter((star) => star.source === 'exoplanet-archive').length; + const where = + archive === 0 + ? 'Positions from measured parallaxes.' + : `Positions from measured parallaxes, and for the ${archive.toLocaleString('en-GB')} planet hosts only the NASA Exoplanet Archive places, from its distances.`; + return `${where} Grid marks the galactic plane through the Sun.`; +} + /** What the catalogue holds, counted by the catalogue describing each star, largest first. */ export function catalogueCensus(stars: readonly StarRecord[]): string { const counts = new Map(); diff --git a/src/app/features/search/search.component.spec.ts b/src/app/features/search/search.component.spec.ts index f5eeb7f..d1b65f2 100644 --- a/src/app/features/search/search.component.spec.ts +++ b/src/app/features/search/search.component.spec.ts @@ -16,7 +16,10 @@ function starRecord(id: number, name: string): StarRecord { /** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */ const STARS: StarRecord[] = [ ...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)), - starRecord(1, 'Proxima Centauri') + starRecord(1, 'Proxima Centauri'), + // As the ETL files a Gaia star: no type, a BP−RP colour; and a star with neither. + { ...starRecord(2, 'TRAPPIST-1'), spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' }, + { ...starRecord(3, 'KMT-2016-BLG-1107L'), spectralType: 'Unknown', colorIndex: null } ]; const IO: BodyRecord = { @@ -109,6 +112,14 @@ describe('SearchComponent', () => { expect(resultNames()).toContain('Proxima Cen b'); }); + it("lists a star by the type its colour gives it where it has none, and never as \"Unknown\"", async () => { + const kindLine = (): string => (element.querySelector('[data-testid="search-results"] button span:last-child')?.textContent ?? '').trim(); + await type('TRAPPIST-1'); + expect(kindLine()).toBe('Star · ~M8'); + await type('KMT-2016-BLG-1107L'); + expect(kindLine()).toBe('Star'); + }); + it('shows nothing for a query that matches nothing', async () => { await type('zzzzz'); expect(element.querySelector('[data-testid="search-results"]')).toBeNull(); diff --git a/src/app/features/search/search.component.ts b/src/app/features/search/search.component.ts index 08b052c..5b72bc5 100644 --- a/src/app/features/search/search.component.ts +++ b/src/app/features/search/search.component.ts @@ -2,6 +2,7 @@ import { Component, computed, ElementRef, output, signal, viewChild } from '@ang import { Router } from '@angular/router'; import { DataLoaderService } from '../../core/data/data-loader.service'; +import { spectralClassification } from '../../shared/astro/spectral'; import { NavigationStore } from '../../shared/state/navigation.store'; import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component'; import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking'; @@ -66,7 +67,7 @@ const KIND_LABELS: Record = { 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" > {{ result.name }} - {{ kindLabel(result.kind) }} · {{ result.subtitle }} + {{ kindLabel(result.kind) }}@if (result.subtitle) { · {{ result.subtitle }}} } @@ -153,7 +154,7 @@ export class SearchComponent { ]); const entries: SearchEntry[] = [ - ...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: star.spectralType, starId: star.id })), + ...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: spectralClassification(star), 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 })) ]; diff --git a/src/app/shared/astro/spectral.spec.ts b/src/app/shared/astro/spectral.spec.ts index e5c8625..7fcef7d 100644 --- a/src/app/shared/astro/spectral.spec.ts +++ b/src/app/shared/astro/spectral.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { dwarfSequenceAtColor, isGiant, parseSpectralClass, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral'; +import { dwarfSequenceAtColor, isGiant, parseSpectralClass, spectralClassification, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral'; describe('parseSpectralClass', () => { it('reads a clean class and subclass', () => { @@ -143,6 +143,14 @@ describe('spectralTypeFromColor', () => { }); }); +describe('spectralClassification', () => { + it("gives the catalogue's type, else the colour's marked as an estimate, else nothing", () => { + expect(spectralClassification({ spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' })).toBe('M5Ve'); + expect(spectralClassification({ spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' })).toBe('~M8'); + expect(spectralClassification({ spectralType: 'Unknown', colorIndex: null })).toBe(''); + }); +}); + describe('dwarfSequenceAtColor', () => { it("puts the Sun's colour in either system at the Sun's temperature and correction", () => { for (const [colour, system] of [[0.65, 'B-V'], [0.823, 'BP-RP']] as const) { diff --git a/src/app/shared/astro/spectral.ts b/src/app/shared/astro/spectral.ts index c26c721..82fa1c9 100644 --- a/src/app/shared/astro/spectral.ts +++ b/src/app/shared/astro/spectral.ts @@ -179,6 +179,19 @@ export function spectralTypeFromColor(colorIndex: number | null, system: 'B-V' | return nearest[0]; } +/** + * A star's classification as a row or an option lists it: the catalogue's type, or the dwarf type + * its colour matches, marked `~` as an estimate; empty with neither, rather than the ETL's literal + * "Unknown", which 383 695 stars carry and search rows and route options used to print. + */ +export function spectralClassification(star: { spectralType: string; colorIndex: number | null; colorSystem?: 'B-V' | 'BP-RP' }): string { + if (star.spectralType && star.spectralType !== 'Unknown') { + return star.spectralType; + } + const estimate = spectralTypeFromColor(star.colorIndex, star.colorSystem); + return estimate ? `~${estimate}` : ''; +} + /** What the dwarf sequence says of a star of a given colour. */ export interface DwarfSequencePoint { /** B−V, the colour in the other system's terms where it was read off BP−RP. */