diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index eed591b..bc02d0f 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -33,9 +33,8 @@ export interface BodyDetailViewModel { /** True when a real photograph is being shown rather than the derived surface. */ hasPhotography: boolean; /** - * Sidereal orbital period. Measured where the archive published one; otherwise derived from the - * semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined - * when neither applies — see `heliocentricPeriodDays`. + * Sidereal orbital period. For a solar-system body, 360 degrees over JPL's published mean + * motion; for an exoplanet, the archive's period where it published one, and undefined where not. */ orbitalPeriodDays?: number; /** @@ -44,4 +43,6 @@ export interface BodyDetailViewModel { * derived surface as a photograph. */ orbitalPeriodSource?: 'measured' | 'derived'; + /** Where the orbit comes from and the span it holds over; see `BodyRecord.orbitSource`. */ + orbitSource?: string; } diff --git a/src/app/features/body-detail/body-readouts.ts b/src/app/features/body-detail/body-readouts.ts index 67611fc..9953ade 100644 --- a/src/app/features/body-detail/body-readouts.ts +++ b/src/app/features/body-detail/body-readouts.ts @@ -65,7 +65,8 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) }); } - return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) }; + const provenance = body.orbitSource ? `${provenanceFor(body)} Orbit: ${body.orbitSource}.` : provenanceFor(body); + return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance }; } /** 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 4792435..91a1e69 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,8 @@ 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 { bodyReadouts } from './body-readouts'; +import { buildBodyViewModel } from './body-view-model'; const orbit = (overrides: Partial = {}): OrbitalElements => ({ semiMajorAxisAu: 1, @@ -51,43 +52,24 @@ const luna: BodyRecord = { orbitSource: 'JPL SSD satellite mean elements, epoch 2000 Jan 1', }; -describe('heliocentricPeriodDays', () => { - it('recovers a known period from the semi-major axis alone', () => { - // P² = a³ in these units, so Earth must come back a year. - expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1); - }); - - it('scales as the three-halves power', () => { - const jupiter: BodyRecord = { - ...earth, - id: 'jupiter', - name: 'Jupiter', - orbit: orbit({ semiMajorAxisAu: 5.2044 }), - }; - // Jupiter's real sidereal period is 4332.6 days. - expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1); - }); - - it('refuses to compute a period for a moon', () => { - // A moon's elements are relative to its planet, whose mass is not in the catalogue — the - // same arithmetic would be wrong by the ratio of that planet's mass to the Sun's. - expect(heliocentricPeriodDays(luna)).toBeUndefined(); - }); -}); - describe('buildBodyViewModel', () => { const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] }; - it('marks a period computed from the semi-major axis as derived', () => { + it('gives a planet the sidereal year its published mean motion goes round in', () => { const model = buildBodyViewModel('earth', catalogues); - expect(model?.orbitalPeriodSource).toBe('derived'); - expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1); + expect(model?.orbitalPeriodSource).toBe('measured'); + expect(model?.orbitalPeriodDays).toBeCloseTo(365.2564, 4); }); - it('leaves a moon without a period rather than inventing one', () => { + it('gives a moon its period too, from the same mean motion that carries it round', () => { + // The card used to refuse, while the scene turned the Moon round the Earth all the same. const model = buildBodyViewModel('luna', catalogues); - expect(model?.orbitalPeriodDays).toBeUndefined(); - expect(model?.orbitalPeriodSource).toBeUndefined(); + expect(model?.orbitalPeriodSource).toBe('measured'); + expect(model?.orbitalPeriodDays).toBeCloseTo(27.32166, 5); + }); + + it('says where the orbit comes from, in the card’s provenance', () => { + expect(bodyReadouts(buildBodyViewModel('luna', catalogues)!).provenance).toContain('Orbit: JPL SSD satellite mean elements, epoch 2000 Jan 1.'); }); it('marks a published exoplanet period as measured, not derived', () => { diff --git a/src/app/features/body-detail/body-view-model.ts b/src/app/features/body-detail/body-view-model.ts index becca17..9df6c18 100644 --- a/src/app/features/body-detail/body-view-model.ts +++ b/src/app/features/body-detail/body-view-model.ts @@ -4,7 +4,7 @@ import { luminositySolar } 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'; -import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model'; +import { StarRecord } from '../../shared/models/star.model'; import { BodyDetailViewModel } from './body-detail.model'; /** Everything the view model is assembled from — the three catalogues, already loaded. */ @@ -41,7 +41,10 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body const body = catalogues.bodies.find((candidate) => candidate.id === id); if (body) { const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId); - const periodDays = heliocentricPeriodDays(body); + // The period the map draws, moons included: JPL's own mean motion, which is also what + // carries the body round the scene. Europa's card had no period at all while the scene + // turned it round Jupiter in 3.55 days. + const periodDays = 360 / body.rates.meanMotionDegPerDay; return { id: body.id, name: body.name, @@ -53,7 +56,8 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)), hasPhotography: bodyTexturePath(body.id) !== undefined, orbitalPeriodDays: periodDays, - orbitalPeriodSource: periodDays === undefined ? undefined : 'derived', + orbitalPeriodSource: 'measured', + orbitSource: body.orbitSource, }; } @@ -81,19 +85,3 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured', }; } - -/** - * Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which - * holds exactly in these units because the Sun's mass is the unit of mass. - * - * Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass - * the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the - * planet's mass to the Sun's — a factor of a thousand for Jupiter. - */ -export function heliocentricPeriodDays(body: BodyRecord): number | undefined { - if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) { - return undefined; - } - const a = body.orbit.semiMajorAxisAu; - return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined; -} 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 89a5a7e..97142b2 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 @@ -831,6 +831,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(navigationStore.viewLevel()).toBe('system'); }); + it('says where a system’s orbits come from, and for the Sun how long they hold', async () => { + const note = (): string => (fixture.componentInstance as unknown as { hudNote: () => string }).hudNote(); + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + expect(note()).toBe('Orbits propagated from JPL mean elements, the planets’ fit for 3000 BC to AD 3000, to the current date.'); + + navigationStore.selectStar(ALPHA_CENTAURI.id); + await flushAsync(); + await advanceFrames(engine, 5); + expect(note()).toBe('Orbits propagated from published elements to the current date.'); + }); + it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => { navigationStore.selectStar(ALPHA_CENTAURI.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 78454f9..45ca160 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -1760,7 +1760,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []), ]); - 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.'); + // Where the orbits come from, and for the Sun how far from the present they hold: each + // body's card names its own source and epoch. + const source = this.bodies.some((body) => body.systemStarId === star.id) ? 'JPL mean elements, the planets’ fit for 3000 BC to AD 3000,' : 'published elements'; + this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'the current date' : 'the date on the clock'}.`); this.hudRange.set( formatAu( this.engine.visibleHalfHeight(