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 e470776..fbcc633 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,8 @@ 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 { closestApproachAu, SUN_RADIUS_AU } from './system-framing'; +import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar'; import { JumpLinkRenderer } from './jump-link-renderer'; import { StarFieldRenderer } from './star-field-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; @@ -31,9 +33,26 @@ const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: // Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index. const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 }; -const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA]; +// A supergiant nothing publishes a radius for, and a white dwarf with neither a colour nor a type +// the parser reads, for what the system view draws each star at; last, so the indices above hold. +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]; 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. +const PROXIMA_B: ExoplanetRecord = { + id: 'Proxima Cen b', + hostStarId: PROXIMA.id, + hostStarName: 'Proxima Cen', + name: 'Proxima Cen b', + hostStarRadiusSolar: 0.141, + hostStarTemperatureK: 2900, + hostStarLuminositySolar: 0.00151, + orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 } +}; + const DEEP_SKY_OBJECT: DeepSkyRecord = { id: 'NGC0224', name: 'Andromeda Galaxy', @@ -160,7 +179,7 @@ class FakeDataLoaderService { } loadExoplanets(): Promise { - return Promise.resolve([]); + return Promise.resolve([PROXIMA_B]); } loadDeepSky(): Promise { @@ -270,8 +289,8 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(refocus).toHaveBeenCalledTimes(1); const [focus] = refocus.mock.calls[0]; expect(focus.view).toBeDefined(); - // The Sun has Earth, so it is a host; the others have nothing catalogued. - expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 0]); + // 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]); }); it('chooses again once the camera has turned half the margin, and not for less', async () => { @@ -932,6 +951,54 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(navigationStore.viewLevel()).toBe('galactic'); }); + describe('each star at its own size and in its own colour', () => { + type DrawnStar = { + starMarkerGeometry: THREE.SphereGeometry; + starTint: { value: THREE.Color }; + controls: { minDistance: number }; + systemRenderer: { object: THREE.Object3D }; + hudReadouts(): { label: string; value: string; derived?: boolean }[]; + }; + + async function enter(star: StarRecord): Promise { + navigationStore.selectStar(star.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + return fixture.componentInstance as unknown as DrawnStar; + } + + function expectColour(colour: THREE.Color, [red, green, blue]: readonly number[]): void { + expect([colour.r, colour.g, colour.b].map((channel) => channel.toFixed(4))).toEqual([red, green, blue].map((channel) => channel.toFixed(4))); + } + + it('draws a host at the radius and in the colour the archive gives it, lights its planets in its light, and says how bright it is', async () => { + const scene = await enter(PROXIMA); + expect(scene.starMarkerGeometry.parameters.radius).toBeCloseTo(0.141 * SUN_RADIUS_AU, 12); + expectColour(scene.starTint.value, blackbodyColor(2900)); + const light = scene.systemRenderer.object.children.find((child): child is THREE.PointLight => child instanceof THREE.PointLight)!; + expectColour(light.color, blackbodyColor(2900, SOLAR_EFFECTIVE_TEMPERATURE_K)); + expect(scene.hudReadouts().find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '0.002 L☉' }); + expect(scene.hudReadouts().find((readout) => readout.label === 'Radius')?.value).toBe('0.141 solar radii'); + }); + + it('keeps the camera three radii out from a supergiant drawn at the radius its colour and brightness give', async () => { + const scene = await enter(ANTARES); + const radius = scene.starMarkerGeometry.parameters.radius; + // 690 R☉ against the 680 Ohnaka et al. (2013) measure: 3.2 AU. + expect(radius / SUN_RADIUS_AU).toBeGreaterThan(600); + expect(radius / SUN_RADIUS_AU).toBeLessThan(760); + expect(scene.controls.minDistance).toBeCloseTo(closestApproachAu(radius), 9); + expect(scene.controls.minDistance).toBeGreaterThan(9); + }); + + it('draws a star nothing gives a size or temperature for as a grey point, not as the Sun', async () => { + const scene = await enter(PROCYON_B); + expect(scene.starMarkerGeometry.parameters.radius).toBeLessThan(SUN_RADIUS_AU / 1000); + expectColour(scene.starTint.value, [1, 1, 1]); + expect(scene.hudReadouts().some((readout) => readout.label === 'Radius')).toBe(false); + }); + }); + it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => { navigationStore.selectStar(SUN.id); await flushAsync(); 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 c1ca4f4..2bd9fb5 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -20,7 +20,7 @@ import { galacticCentrePositionPc, galacticToEquatorial, } from '../../shared/astro/galaxy'; -import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar'; +import { blackbodyColor } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService, SceneCamera } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; @@ -89,6 +89,14 @@ import { catalogueCensus, 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; +/** + * What a star nothing gives a radius for is drawn at: 150 km, far under the pixel floor from any + * distance the camera can reach, so it is the floor's point, the size of no star in particular. + * Drawn at the Sun's radius, PSR J1719-1438 — a neutron star, 10 km across — swallowed the planet + * it holds at 0.0044 AU, and Procyon B, a white dwarf of 0.012 R☉, was drawn 81 times too wide. + */ +const UNMEASURED_STAR_RADIUS_AU = 1e-6; + /** * The linear limb-darkening coefficient: a star's surface is I(μ) = I(1) (1 − u (1 − μ)) bright, * where μ is the cosine of the angle between the line of sight and the surface normal. The Sun's @@ -2221,7 +2229,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // measures exoplanet inclinations against. The Sun sits at the origin and has no // exoplanets, so it has no meaningful direction and the renderer falls back. // Every star at its own radius: the archive's for a planet host, otherwise derived from its - // colour and brightness — or the Sun's, for the 3 077 stars with no measured magnitude or with + // colour and brightness — or a point, for the 2 858 stars with no measured magnitude or with // neither a colour nor a type, which the card then gives no radius. Its temperature is the // colour of its disc and of the light it casts, and its luminosity — the archive's, or else // derived from its magnitude and distance — decides how hot each body in the system is, and @@ -2237,7 +2245,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemGroup.add(this.systemRenderer.object); this.applyDisplay(this.display()); - const starRadiusAu = (this.currentStarSurface.radiusSolar ?? 1) * SUN_RADIUS_AU; + const starRadiusAu = this.currentStarSurface.radiusSolar === null ? UNMEASURED_STAR_RADIUS_AU : this.currentStarSurface.radiusSolar * SUN_RADIUS_AU; // Framed against the grid's outer ring rather than the outermost orbit — the ring is always // the wider of the two — or against the star, for a giant wider than both; and against the @@ -2255,10 +2263,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 64, 32); this.starMarkerMaterial ??= starSurfaceMaterial(this.starTint); - this.starTint.value.setRGB( - ...blackbodyColor(this.currentStarSurface.temperatureK ?? SOLAR_EFFECTIVE_TEMPERATURE_K), - THREE.LinearSRGBColorSpace, - ); + // Grey, the photograph's own, where there is no temperature: the Sun's colour would say it is one. + const temperatureK = this.currentStarSurface.temperatureK; + const [red, green, blue] = temperatureK === null ? [1, 1, 1] : blackbodyColor(temperatureK); + this.starTint.value.setRGB(red, green, blue, THREE.LinearSRGBColorSpace); // No halo. It was a sprite sized against the arrival frame — 1.12 AU for the Sun — so it // stayed put as the camera closed in and ended up filling the screen with the flat gradient // that was meant to dress the star, over the photograph underneath it.