diff --git a/src/app/features/body-detail/body-view-model.spec.ts b/src/app/features/body-detail/body-view-model.spec.ts index a76f661..4d9d47a 100644 --- a/src/app/features/body-detail/body-view-model.spec.ts +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { BodyRecord, OrbitalElements } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model'; -import { buildBodyViewModel, heliocentricPeriodDays } from './body-view-model'; +import { buildBodyViewModel, heliocentricPeriodDays, starSurfaceOf } from './body-view-model'; const orbit = (overrides: Partial = {}): OrbitalElements => ({ semiMajorAxisAu: 1, @@ -122,3 +122,36 @@ describe('buildBodyViewModel', () => { expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID); }); }); + +describe('starSurfaceOf', () => { + // Proxima Centauri as HYG describes it, and one of its planets' archive rows. + const proxima: StarRecord = { id: 70666, name: 'Proxima Centauri', x: 1.2959, y: 0, z: 0, magnitude: 11.01, magnitudeBand: 'V', spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' }; + const proximaB: ExoplanetRecord = { id: 'proxima-cen-b', hostStarId: 70666, hostStarName: 'Proxima Cen', name: 'Proxima Cen b', orbit: { semiMajorAxisAu: 0.0485 } }; + + it("is the Sun's own for the Sun, and not derived", () => { + expect(starSurfaceOf(sun, [])).toEqual({ radiusSolar: 1, radiusDerived: false, temperatureK: 5772 }); + }); + + it("takes a host's radius and temperature from the archive", () => { + const surface = starSurfaceOf(proxima, [{ ...proximaB, hostStarRadiusSolar: 0.141, hostStarTemperatureK: 2900 }]); + expect(surface).toEqual({ radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900 }); + }); + + it('derives both otherwise, and says the radius is derived', () => { + const surface = starSurfaceOf(proxima, [proximaB]); + expect(surface.radiusDerived).toBe(true); + // Its colour reads as an M5 dwarf: 3 068 K and 0.105 R☉, against 2 900 K and 0.154 R☉ + // measured (Kervella et al. 2017). B−V barely changes along the late M dwarfs. + expect(surface.temperatureK).toBeCloseTo(3068, -1); + expect(surface.radiusSolar).toBeCloseTo(0.105, 2); + }); + + it('has no radius for a star with neither a colour nor a type', () => { + expect(starSurfaceOf({ ...proxima, colorIndex: null, spectralType: 'Unknown' }, []).radiusSolar).toBeNull(); + }); + + it('has none from a magnitude no survey measured', () => { + // No band: the magnitude is the ETL's stand-in, and the luminosity from it means nothing. + expect(starSurfaceOf({ ...proxima, magnitudeBand: undefined }, []).radiusSolar).toBeNull(); + }); +}); diff --git a/src/app/features/body-detail/body-view-model.ts b/src/app/features/body-detail/body-view-model.ts index b580d9f..1615eb1 100644 --- a/src/app/features/body-detail/body-view-model.ts +++ b/src/app/features/body-detail/body-view-model.ts @@ -1,6 +1,6 @@ import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance'; import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance'; -import { luminositySolar } from '../../shared/astro/stellar'; +import { effectiveTemperatureK, luminositySolar, radiusFromLuminositySolar, SOLAR_EFFECTIVE_TEMPERATURE_K, StellarPhotometry } from '../../shared/astro/stellar'; import { bodyTexturePath } from '../../shared/rendering/texture-catalog'; import { BodyRecord } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; @@ -20,17 +20,54 @@ export interface BodyCatalogues { * off the spectral type where there is no colour. */ export function luminosityOf(star: StarRecord | undefined): number | null { - if (!star) { - return null; - } - return luminositySolar({ + return star ? luminositySolar(photometryOf(star)) : null; +} + +function photometryOf(star: StarRecord): StellarPhotometry { + return { magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType, magnitudeBand: star.magnitudeBand, colorIndex: star.colorIndex, colorSystem: star.colorSystem, - }); + }; +} + +/** How big and how hot a star is, and whether the radius was measured or derived here. */ +export interface StarSurface { + /** Solar radii; `null` without a published radius, a measured magnitude, and a colour or type. */ + radiusSolar: number | null; + radiusDerived: boolean; + temperatureK: number | null; +} + +/** + * A star's radius and effective temperature: the archive's `st_rad` and `st_teff` for a planet + * host, from any of its planets' rows, and otherwise derived — the temperature off the dwarf + * sequence at the star's colour, the radius from that and the luminosity (Stefan-Boltzmann). + * The Sun's are its own, the nominal values the rest are measured in. + * + * Derived radii land within a factor of 1.5 of the archive's for 97 % of the 1 447 catalogue + * hosts that have both, and within 0.018 dex at the median. + */ +export function starSurfaceOf(star: StarRecord, planets: readonly ExoplanetRecord[]): StarSurface { + if (star.id === SUN_STAR_ID) { + return { radiusSolar: 1, radiusDerived: false, temperatureK: SOLAR_EFFECTIVE_TEMPERATURE_K }; + } + const temperatureK = planets.find((planet) => planet.hostStarTemperatureK)?.hostStarTemperatureK ?? effectiveTemperatureK(photometryOf(star)); + const measured = planets.find((planet) => planet.hostStarRadiusSolar)?.hostStarRadiusSolar; + if (measured) { + return { radiusSolar: measured, radiusDerived: false, temperatureK }; + } + // Not from the ETL's stand-in magnitude, which is all 309 stars without a band have: PSR + // J1719-1438 came out 2.3 solar radii, wider than its planet's orbit. + const luminosity = star.magnitudeBand ? luminosityOf(star) : null; + return { + radiusSolar: luminosity !== null && temperatureK !== null ? radiusFromLuminositySolar(luminosity, temperatureK) : null, + radiusDerived: true, + temperatureK, + }; } /** 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 2d46871..297d21c 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -40,7 +40,7 @@ import { DeepSkyRenderer } from './deep-sky-renderer'; import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; import { MilkyWayRenderer } from './milky-way-renderer'; import { - starMarkerRadiusAu, + closestApproachAu, SUN_RADIUS_AU, systemFrameRadiusAu, systemFramingDistanceAu, @@ -55,7 +55,7 @@ import { type ScaleBar, } from '../../shared/format/scale-bar'; import { BodyDetailViewModel } from '../body-detail/body-detail.model'; -import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model'; +import { buildBodyViewModel, luminosityOf, starSurfaceOf, StarSurface } from '../body-detail/body-view-model'; import { DEFAULT_HUD_DISPLAY, HudDisplay, @@ -284,7 +284,6 @@ const GALACTIC_LEVEL_THRESHOLD = 0.5; const SYSTEM_NEAR_AU = 0.002; const SYSTEM_FAR_AU = 20000; -const SYSTEM_MIN_DISTANCE_AU = 0.05; const SYSTEM_MAX_DISTANCE_AU = 5000; /** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */ const SYSTEM_ENTRY_DISTANCE_AU = 200; @@ -415,7 +414,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private readonly galaxyGroup = new THREE.Group(); private readonly systemGroup = new THREE.Group(); private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); - /** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */ + /** Rebuilt per system, since every star has its own radius. */ private starMarkerGeometry?: THREE.SphereGeometry; /** Readout panel contents, refreshed on the same cadence as the labels rather than per frame. */ @@ -551,6 +550,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private currentStarId: number | null = null; private systemRenderer?: SystemOrbitsRenderer; private starMarker?: THREE.Mesh; + /** The system's star's radius and temperature, worked out once on entering it. */ + private currentStarSurface?: StarSurface; constructor( private readonly engine: EngineService, @@ -1754,7 +1755,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}`, }, - ...starReadouts(star, luminosityOf(star)), + ...starReadouts(star, luminosityOf(star), this.currentStarSurface), ]); this.hudNote.set(this.time.atNow() ? 'Orbits propagated from published elements to the current date.' : 'Orbits propagated from published elements to the date on the clock.'); this.hudRange.set( @@ -2203,10 +2204,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemGroup.add(this.systemRenderer.object); this.applyDisplay(this.display()); + // 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 + // neither a colour nor a type, which the card then gives no radius. + this.currentStarSurface = starSurfaceOf(star, systemExoplanets); + const starRadiusAu = (this.currentStarSurface.radiusSolar ?? 1) * SUN_RADIUS_AU; + // Framed against the grid's outer ring rather than the outermost orbit — the ring is always - // the wider of the two — and against the camera this scene actually has, so the margin holds - // whatever the window shape. Computed before the star, because how far away the star will be - // seen from is what decides how big its halo has to be to stay visible. + // the wider of the two — or against the star, for a giant wider than both; and against the + // camera this scene actually has, so the margin holds whatever the window shape. // Framed against the perspective camera whichever is active: the framing distance is what // the orthographic frustum is then sized from, so both projections show the same extent. const framingCamera = this.engine.getPerspectiveCamera(); @@ -2214,14 +2220,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const framingDistance = systemFramingDistanceAu( this.systemRenderer.gridOuterRadiusAu, viewport, + starRadiusAu, ); - - // The Sun at its own radius; every other star sized against its innermost orbit, which is all - // the catalogue supports, and which at least never lets it swallow its own planets. - const starRadiusAu = - star.id === SOL_STAR_ID - ? SUN_RADIUS_AU - : starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu); this.starMarkerGeometry?.dispose(); this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 64, 32); @@ -2255,7 +2255,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { depthCamera.near = SYSTEM_NEAR_AU; depthCamera.far = SYSTEM_FAR_AU; depthCamera.updateProjectionMatrix(); - this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU; + this.controls!.minDistance = closestApproachAu(starRadiusAu); this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU; this.resetZoom(); diff --git a/src/app/features/galaxy-system/star-readouts.spec.ts b/src/app/features/galaxy-system/star-readouts.spec.ts index 50bcc38..4ad65ae 100644 --- a/src/app/features/galaxy-system/star-readouts.spec.ts +++ b/src/app/features/galaxy-system/star-readouts.spec.ts @@ -67,6 +67,18 @@ describe('starReadouts', () => { expect(value(starReadouts({ ...HYG_STAR, source: 'exoplanet-archive' }, null), 'Source')).toBe('NASA Exoplanet Archive'); }); + it('says a radius was derived, and from what; a published one plainly', () => { + expect(starReadouts(PLACED_BY_GAIA, null, { radiusSolar: 0.1049, radiusDerived: true, temperatureK: 3068 }).find((readout) => readout.label === 'Radius')).toEqual({ + label: 'Radius', + value: '~0.10 solar radii, from colour and brightness', + derived: true + }); + expect(value(starReadouts(PLACED_BY_GAIA, null, { radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900 }), 'Radius')).toBe('0.141 solar radii'); + expect(value(starReadouts(HYG_STAR, null, { radiusSolar: 584.3, radiusDerived: true, temperatureK: 3590 }), 'Radius')).toBe('~580 solar radii, from colour and brightness'); + expect(value(starReadouts(HYG_STAR, null, { radiusSolar: 1, radiusDerived: false, temperatureK: 5772 }), 'Radius')).toBe('1.00 solar radii'); + expect(starReadouts(HYG_STAR, null, { radiusSolar: null, radiusDerived: true, temperatureK: null }).some((readout) => readout.label === 'Radius')).toBe(false); + }); + it('marks the luminosity as derived', () => { expect(starReadouts(HYG_STAR, 25.4).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '25.40 L☉', derived: true }); }); diff --git a/src/app/features/galaxy-system/star-readouts.ts b/src/app/features/galaxy-system/star-readouts.ts index 0c347ea..5f34149 100644 --- a/src/app/features/galaxy-system/star-readouts.ts +++ b/src/app/features/galaxy-system/star-readouts.ts @@ -1,6 +1,7 @@ import { spectralTypeFromColor } 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'; import { HudReadout } from '../hud/hud-dock.component'; /** @@ -33,9 +34,9 @@ export function starSubtitle(star: StarRecord): string { /** * A star's measured readouts, each with what it was measured in: the band of its magnitude, which * colour its colour index is, the distance's uncertainty, and the catalogues they come from. - * `luminosity` is derived, and marked so. + * `luminosity` is derived, and marked so; so is a radius derived from it, and it says from what. */ -export function starReadouts(star: StarRecord, luminosity: number | null): HudReadout[] { +export function starReadouts(star: StarRecord, luminosity: number | null, surface?: StarSurface): HudReadout[] { const distancePc = Math.hypot(star.x, star.y, star.z); const catalogue = describingCatalogue(star); return [ @@ -48,10 +49,20 @@ export function starReadouts(star: StarRecord, luminosity: number | null): HudRe ? [{ label: 'Colour', value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}` }] : []), ...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []), + ...(surface?.radiusSolar ? [radiusReadout(surface.radiusSolar, surface.radiusDerived)] : []), { label: 'Source', value: catalogue === 'HYG' && star.distanceFromGaia ? 'HYG, Gaia DR3 distance' : catalogue } ]; } +/** Two figures for a derived radius, three for a published one: 0.105 is not what colour gives. */ +function radiusReadout(radiusSolar: number, derived: boolean): HudReadout { + const digits = derived ? 2 : 3; + const figure = radiusSolar.toLocaleString('en-GB', { minimumSignificantDigits: digits, maximumSignificantDigits: digits }); + return derived + ? { label: 'Radius', value: `~${figure} solar radii, from colour and brightness`, derived: true } + : { label: 'Radius', value: `${figure} solar radii` }; +} + /** 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/galaxy-system/system-framing.spec.ts b/src/app/features/galaxy-system/system-framing.spec.ts index 4bd2c68..1f38a90 100644 --- a/src/app/features/galaxy-system/system-framing.spec.ts +++ b/src/app/features/galaxy-system/system-framing.spec.ts @@ -4,8 +4,7 @@ import { describe, expect, it } from 'vitest'; import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates'; import { bodyMarkerRadiusAu, - DEFAULT_STAR_MARKER_RADIUS_AU, - starMarkerRadiusAu, + closestApproachAu, systemFrameRadiusAu, systemFramingDistanceAu, systemGridRingsAu, @@ -19,39 +18,6 @@ const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 }; const GL_357 = { innermost: 0.035, outermost: 0.204 }; const SOLAR = { innermost: 0.387, outermost: 30.07 }; -describe('starMarkerRadiusAu', () => { - it('never reaches the innermost orbit', () => { - for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) { - expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost); - } - }); - - it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => { - // Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system. - expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost); - expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost); - }); - - it('never grows beyond the default, however wide the system', () => { - expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU); - expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU); - }); - - it('scales in proportion to the innermost orbit', () => { - expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9); - }); - - it('falls back to the default when there are no planets to scale against', () => { - for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { - expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU); - } - }); - - it('stays positive for an extremely tight orbit', () => { - expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0); - }); -}); - describe('systemFramingDistanceAu', () => { it('fits the radius it is given in view, with room around it', () => { for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) { @@ -88,6 +54,15 @@ describe('systemFramingDistanceAu', () => { expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9); }); + it('backs off to hold a giant wider than its system, and leaves a dwarf to the system', () => { + // Betelgeuse drawn at 584 solar radii, 2.7 AU, with nothing around it; the Sun inside its own. + const betelgeuseAu = 2.72; + expect(systemFrameRadiusAu(systemFramingDistanceAu(0, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu); + expect(systemFrameRadiusAu(systemFramingDistanceAu(0.5, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu); + expect(systemFramingDistanceAu(SOLAR.outermost, undefined, 0.00465)).toBe(systemFramingDistanceAu(SOLAR.outermost)); + expect(systemFramingDistanceAu(0, undefined, 0.00465)).toBe(systemFramingDistanceAu(0)); + }); + it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => { expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000)); }); @@ -154,25 +129,10 @@ describe('the grid and the framing together', () => { }); }); -describe('star and framing together', () => { - it('gives compact and wide systems a comparable apparent star size', () => { - // Both scale with the system, so the star subtends a similar angle either way — the point - // of deriving them from the same measurements rather than fixing them. - const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) => - starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost); - - const compact = apparent(TRAPPIST_1); - const midRange = apparent(GL_357); - - expect(compact).toBeGreaterThan(0); - expect(compact / midRange).toBeGreaterThan(0.25); - expect(compact / midRange).toBeLessThan(4); - }); - - it('always leaves the innermost orbit outside the star, at every scale', () => { - for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) { - expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost); - } +describe('closestApproachAu', () => { + it('keeps the camera three radii out from a giant, and at the old floor for the Sun', () => { + expect(closestApproachAu(0.00465)).toBe(0.05); + expect(closestApproachAu(2.72)).toBeCloseTo(8.16, 9); }); }); diff --git a/src/app/features/galaxy-system/system-framing.ts b/src/app/features/galaxy-system/system-framing.ts index a410df7..73e221f 100644 --- a/src/app/features/galaxy-system/system-framing.ts +++ b/src/app/features/galaxy-system/system-framing.ts @@ -12,21 +12,11 @@ import { CartesianCoordinates } from '../../shared/astro/coordinates'; * the star marker, so they rendered as a lone sphere with nothing around it, and 52% were * framed from a distance floor far larger than the system itself. * - * Both quantities are therefore derived from the system's own scale. Because the star and the - * camera scale together, a compact system ends up looking like a wide one: same apparent star, - * same apparent spread of orbits. + * The camera's distance is therefore derived from the system's own scale. The star is not: it is + * drawn at its own radius, like every body here, and the framing only makes room for it when the + * star is a giant wider than its system. */ -/** Star size when there are no orbits to scale against, and the ceiling everywhere else. */ -export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2; - -/** - * Star radius as a fraction of the innermost orbit. Comfortably below 1 so there is visible - * space between the star's limb and the closest orbit, rather than the orbit grazing or - * disappearing inside it. - */ -const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45; - /** * Clear space left around the framed radius, as a fraction of it. The camera backs off this * much further than the geometry strictly needs, so the outermost ring sits inside the frame @@ -103,20 +93,6 @@ export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vec return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame); } -/** - * Radius (AU) to draw the system's star at, given its innermost orbit. - * - * Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the - * closest orbit. Falls back to that default when the system has no planets, since there is - * then nothing for the star to crowd. - */ -export function starMarkerRadiusAu(innermostOrbitAu: number): number { - if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) { - return DEFAULT_STAR_MARKER_RADIUS_AU; - } - return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT); -} - /** * Radius, in AU, that the camera can see at the star's own distance — the half-height of the * view frustum where the system sits, along whichever screen axis is tighter. @@ -138,14 +114,32 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport * Callers pass the outermost thing actually drawn, which is the reference grid's outer ring * rather than the outermost orbit — the ring is always the wider of the two, by construction. */ -export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number { +export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT, starRadiusAu = 0): number { + // A giant drawn at its own radius can be wider than the system around it — Betelgeuse's 584 + // solar radii are 2.7 AU — or than the empty framing, and the camera must not settle inside it. + const star = (starRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport); if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) { - return EMPTY_SYSTEM_FRAMING_DISTANCE_AU; + return Math.max(EMPTY_SYSTEM_FRAMING_DISTANCE_AU, star); } - const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport); + const required = Math.max((framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport), star); return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU); } +/** How close the camera may come to the star's centre, whatever the star: ten solar radii. */ +const MIN_APPROACH_AU = 0.05; +/** From three radii out a star spans 39 degrees, most of the view's 50, and the camera stays out of it. */ +const STAR_CLEARANCE_RADII = 3; + +/** + * The orbit controls' minimum distance in a system. The fixed 0.05 AU it used to be leaves the + * Sun 11 degrees across; but 23 211 stars on the map are drawn wider than 3.6 solar radii, which + * puts 0.05 AU inside three of their radii, and a giant's surface further out still — a zoom + * would have carried the camera through it. + */ +export function closestApproachAu(starRadiusAu: number): number { + return Math.max(MIN_APPROACH_AU, STAR_CLEARANCE_RADII * starRadiusAu); +} + /** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */ const TARGET_GRID_RING_COUNT = 8; const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15; @@ -192,13 +186,8 @@ const KM_PER_AU = 149597870.7; const DEFAULT_BODY_RADIUS_KM = 6371; /** - * The Sun's own radius, in AU — the one star whose size this map knows. - * - * Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost - * orbit rather than measured, because no stellar radius reaches the app: the catalogue carries - * positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn - * here, and until the ETL fetches it, a system's star is the one body in the view that is not - * to scale. + * The Sun's own radius, in AU: the unit every star's radius is drawn in, the archive's or the one + * `starSurfaceOf` derives from its colour and brightness. */ export const SUN_RADIUS_AU = 696340 / KM_PER_AU; diff --git a/src/app/features/galaxy-system/system-orbits-renderer.ts b/src/app/features/galaxy-system/system-orbits-renderer.ts index 37f5076..508f9f9 100644 --- a/src/app/features/galaxy-system/system-orbits-renderer.ts +++ b/src/app/features/galaxy-system/system-orbits-renderer.ts @@ -264,8 +264,6 @@ export class SystemOrbitsRenderer { readonly members: readonly SystemMember[]; /** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */ readonly maxTopLevelSemiMajorAxisAu: number; - /** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */ - readonly minTopLevelSemiMajorAxisAu: number; /** * The plane this system is read against, as a rotation from XY into the scene's equatorial * frame: the ecliptic for the solar system, the plane of the sky for everything else. @@ -308,7 +306,6 @@ export class SystemOrbitsRenderer { ...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!) ].filter((axis) => Number.isFinite(axis) && axis > 0); this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0; - this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0; for (const body of bodies) { if (!body.parentBodyId) { diff --git a/src/app/shared/astro/stellar.spec.ts b/src/app/shared/astro/stellar.spec.ts index 04b9a08..118ad81 100644 --- a/src/app/shared/astro/stellar.spec.ts +++ b/src/app/shared/astro/stellar.spec.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar'; +import { + absoluteMagnitude, + bolometricCorrection, + effectiveTemperatureK, + luminositySolar, + radiusFromLuminositySolar, + SOLAR_ABSOLUTE_MAGNITUDE_V, + SOLAR_BOLOMETRIC_MAGNITUDE, + SOLAR_EFFECTIVE_TEMPERATURE_K +} from './stellar'; /** Real catalogue rows, with the published luminosity each one should reproduce. */ const SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 }; @@ -122,3 +131,36 @@ describe('luminositySolar', () => { expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull(); }); }); + +describe('effectiveTemperatureK', () => { + it("is the Sun's own for the Sun", () => { + expect(effectiveTemperatureK({ magnitude: -26.7, distancePc: 0, colorIndex: 0.7 })).toBe(SOLAR_EFFECTIVE_TEMPERATURE_K); + }); + + it('reads a colour in its own system, and a spectral type where there is no colour', () => { + // An M5 dwarf is 3 060 K at B−V 1.83 or BP−RP 3.35. Its type alone goes through the colour + // `spectralTypeToColorIndex` gives it, B−V 1.70, and comes out a little warmer. + expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 3.35, colorSystem: 'BP-RP' })).toBeCloseTo(3060, 0); + expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 1.83, colorSystem: 'B-V' })).toBeCloseTo(3060, 0); + expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'M5Ve', colorIndex: null })).toBeCloseTo(3106, 0); + expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'Unknown', colorIndex: null })).toBeNull(); + }); +}); + +describe('radiusFromLuminositySolar', () => { + it('is one for the Sun', () => { + expect(radiusFromLuminositySolar(1, SOLAR_EFFECTIVE_TEMPERATURE_K)).toBeCloseTo(1, 12); + }); + + it('gives Sirius and TRAPPIST-1 their published radii from colour and brightness alone', () => { + // 1.711 R☉ (Liebert et al. 2005) and 0.119 R☉ (Agol et al. 2021), each to within a fifth. + for (const [star, published] of [ + [{ magnitude: -1.44, distancePc: 2.6371, magnitudeBand: 'V', colorIndex: 0.009, colorSystem: 'B-V' }, 1.711], + [{ magnitude: 15.6226, distancePc: 12.467, magnitudeBand: 'G', colorIndex: 4.902, colorSystem: 'BP-RP' }, 0.119] + ] as const) { + const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!); + expect(radius / published).toBeGreaterThan(0.8); + expect(radius / published).toBeLessThan(1.2); + } + }); +}); diff --git a/src/app/shared/astro/stellar.ts b/src/app/shared/astro/stellar.ts index 07a51f8..e946082 100644 --- a/src/app/shared/astro/stellar.ts +++ b/src/app/shared/astro/stellar.ts @@ -1,4 +1,4 @@ -import { dwarfSequenceAtColor, parseSpectralClass, SpectralClass } from './spectral'; +import { dwarfSequenceAtColor, parseSpectralClass, SpectralClass, spectralTypeToColorIndex } from './spectral'; /** * Stellar luminosity, derived from the two things the star catalogue actually measures. @@ -135,3 +135,27 @@ export function luminositySolar(star: StellarPhotometry): number | null { return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR); } +/** The Sun's effective temperature, the IAU 2015 nominal value. */ +export const SOLAR_EFFECTIVE_TEMPERATURE_K = 5772; + +/** + * Effective temperature, off the dwarf sequence at the star's colour, or at the colour its + * spectral type implies where it has none. Exactly the Sun's for the Sun, which is at zero + * distance here. A giant is read as the dwarf of its colour: a few hundred kelvin too cool at K. + */ +export function effectiveTemperatureK(star: StellarPhotometry): number | null { + if (star.distancePc === 0) { + return SOLAR_EFFECTIVE_TEMPERATURE_K; + } + const measured = star.colorIndex != null ? dwarfSequenceAtColor(star.colorIndex, star.colorSystem) : null; + return (measured ?? dwarfSequenceAtColor(spectralTypeToColorIndex(star.spectralType)))?.temperatureK ?? null; +} + +/** + * Radius in solar radii from luminosity and temperature — Stefan-Boltzmann, L = 4πR²σT⁴, in solar + * units. Luminosity-class blind, since the luminosity comes from the distance: a giant comes out a + * giant whatever the sequence took it for. + */ +export function radiusFromLuminositySolar(luminositySolar: number, temperatureK: number): number { + return Math.sqrt(luminositySolar) / (temperatureK / SOLAR_EFFECTIVE_TEMPERATURE_K) ** 2; +}