diff --git a/.github/workflows/junie-review.yml b/.github/workflows/junie-review.yml new file mode 100644 index 0000000..2694e50 --- /dev/null +++ b/.github/workflows/junie-review.yml @@ -0,0 +1,69 @@ +name: Junie review + +# JetBrains' Junie agent reads each pull request and leaves inline review comments. It runs +# alongside CI rather than as part of it: CI answers whether the code works, this answers whether +# it reads well, and a review comment should never be able to turn the build red. +on: + pull_request: + types: [opened, synchronize, ready_for_review] + +# A review of the previous push is stale the moment a new one lands, so supersede it rather than +# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref +# so a push to `main` never cancels a review. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +# Read the code, write the review. `issues: write` is what posts the summary comment — GitHub +# treats a pull request's conversation timeline as an issue. +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + review: + name: Review the diff + runs-on: ubuntu-latest + # Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets + # from `pull_request` runs on forked branches, so the job would fail on a missing key rather + # than say anything useful about the code. + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + env: + # Whether the key exists, resolved once here because `secrets` is not one of the contexts a + # step's `if` can read, while `env` is. + HAS_JUNIE_KEY: ${{ secrets.JUNIE_API_KEY != '' }} + steps: + # Without the key the action exits on "Missing required input", which would mark every pull + # request failed for a reason that has nothing to do with its code. Say so in the run + # summary and stop instead — visible to anyone who looks, blocking nobody who doesn't. + - name: Explain the absent key + if: env.HAS_JUNIE_KEY != 'true' + run: | + echo "No \`JUNIE_API_KEY\` secret is set, so this pull request was not reviewed." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Generate a key at and add it under" >> "$GITHUB_STEP_SUMMARY" + echo "Settings → Secrets and variables → Actions." >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/checkout@v5 + if: env.HAS_JUNIE_KEY == 'true' + with: + # Junie reads the diff through the GitHub API, so the full history it would otherwise + # clone is never used. + fetch-depth: 1 + + - uses: JetBrains/junie-github-action@v1 + if: env.HAS_JUNIE_KEY == 'true' + # An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the + # log, but it is not a reason to hold a pull request whose tests pass. + continue-on-error: true + with: + junie_api_key: ${{ secrets.JUNIE_API_KEY }} + # The action's built-in review prompt. Replace with a prompt block to review against + # criteria of our own. + prompt: code-review + # Rewrite one comment on each push instead of stacking a new one per revision, so the + # conversation shows the current state of the review rather than its history. + use_single_comment: "true" diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 667b9ba..a561593 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -6,9 +6,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; -import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance'; -import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance'; -import { luminositySolar } from '../../shared/astro/stellar'; import { planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; @@ -17,6 +14,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; import { BodyDetailViewModel } from './body-detail.model'; +import { buildBodyViewModel } from './body-view-model'; import { InfoPanelComponent } from './info-panel.component'; /** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */ @@ -118,44 +116,18 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { return; } - const body = this.bodies.find((candidate) => candidate.id === id); - const exoplanet = this.exoplanets.find((candidate) => candidate.id === id); - - if (body) { - const hostStar = this.stars.find((star) => star.id === body.systemStarId); - this.viewModel.set({ - id: body.id, - name: body.name, - kind: body.kind, - hostStarName: hostStar?.name ?? 'Unknown star', - radiusKm: body.radiusKm, - orbit: body.orbit, - appearance: appearanceForBody(body, this.bodies, this.luminosityOf(hostStar)), - hasPhotography: bodyTexturePath(body.id) !== undefined - }); - this.navigationStore.selectStar(body.systemStarId); - } else if (exoplanet) { - const hostStar = this.stars.find((star) => star.id === exoplanet.hostStarId); - this.viewModel.set({ - id: exoplanet.id, - name: exoplanet.name, - kind: 'exoplanet', - hostStarName: exoplanet.hostStarName, - radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined, - massEarth: exoplanet.massEarth, - discoveryYear: exoplanet.discoveryYear, - orbit: exoplanet.orbit, - appearance: appearanceForExoplanet(exoplanet, this.luminosityOf(hostStar)), - hasPhotography: bodyTexturePath(exoplanet.id) !== undefined - }); - if (exoplanet.hostStarId !== null) { - this.navigationStore.selectStar(exoplanet.hostStarId); - } - } else { + // Shared with the system view's object card, so the same body cannot read differently there. + const viewModel = buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }); + if (!viewModel) { this.viewModel.set(undefined); this.notFound.set(true); return; } + this.viewModel.set(viewModel); + + if (viewModel.hostStarId !== undefined) { + this.navigationStore.selectStar(viewModel.hostStarId); + } this.notFound.set(false); this.navigationStore.selectBody(id); @@ -164,18 +136,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { } } - /** - * The host star's luminosity in solar units, from its own catalogued magnitude and distance. - * `null` for an exoplanet whose host never cross-referenced to the star catalogue, which - * leaves its planets with no derived temperature rather than a guessed one. - */ - private luminosityOf(star: StarRecord | undefined): number | null { - if (!star) { - return null; - } - return luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType }); - } - private applyViewModelToScene(): void { const viewModel = this.viewModel(); if (!viewModel || !this.planetMaterial) { diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index 89bebd8..eed591b 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -13,6 +13,12 @@ export interface BodyDetailViewModel { name: string; kind: BodyDetailKind; hostStarName: string; + /** + * The host star's catalogue id, so a caller that has the view model does not have to rescan the + * catalogues to find what building it already resolved. Undefined for an exoplanet whose host + * never cross-referenced to the star catalogue. + */ + hostStarId?: number; radiusKm?: number; massEarth?: number; discoveryYear?: number; @@ -26,4 +32,16 @@ export interface BodyDetailViewModel { appearance: PlanetAppearance; /** 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`. + */ + orbitalPeriodDays?: number; + /** + * Which of those two the period is, so the surfaces can file it under the right heading. A + * derived period presented as an observation is the same category of error as presenting a + * derived surface as a photograph. + */ + orbitalPeriodSource?: 'measured' | 'derived'; } diff --git a/src/app/features/body-detail/body-readouts.ts b/src/app/features/body-detail/body-readouts.ts new file mode 100644 index 0000000..67611fc --- /dev/null +++ b/src/app/features/body-detail/body-readouts.ts @@ -0,0 +1,82 @@ +import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance'; +import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity'; +import { Readout } from '../../shared/models/readout'; +import { BodyDetailViewModel } from './body-detail.model'; + +export const KIND_LABELS: Readonly> = { + planet: 'Planet', + moon: 'Moon', + dwarf: 'Dwarf planet', + exoplanet: 'Exoplanet' +}; + +export interface BodyReadouts { + readonly kindLabel: string; + /** Only what a catalogue actually published for this body. */ + readonly measured: readonly Readout[]; + /** Everything computed from those measurements rather than observed directly. */ + readonly derived: readonly Readout[]; + /** Says plainly which of the two the surface being drawn is. */ + readonly provenance: string; +} + +/** + * Turns a body into the rows its panels display. + * + * Shared by the in-map card and the detail page for the same reason `buildBodyViewModel` is: + * two independent assemblies of "what do we know about this world" drift, and here the drift + * would be in which side of the measured/derived line a quantity falls on — which is the one + * distinction these panels exist to make. + */ +export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts { + const measured: Readout[] = []; + if (body.radiusKm !== undefined) { + measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) }); + } + if (body.massEarth !== undefined) { + measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) }); + } + if (body.orbit.semiMajorAxisAu !== undefined) { + measured.push({ label: 'Semi-major axis', value: formatAu(body.orbit.semiMajorAxisAu) }); + } + if (body.orbit.eccentricity !== undefined) { + measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) }); + } + if (body.orbit.inclinationDeg !== undefined) { + measured.push({ label: 'Inclination', value: `${body.orbit.inclinationDeg.toFixed(2)}°` }); + } + // The period sits under whichever heading its provenance calls for. Same number, same field — + // a published period is an observation and a computed one is not. + if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'measured') { + measured.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) }); + } + if (body.discoveryYear !== undefined) { + measured.push({ label: 'Discovered', value: `${body.discoveryYear}` }); + } + + const derived: Readout[] = [{ label: 'Class', value: PLANET_CLASS_LABELS[body.appearance.planetClass] }]; + if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'derived') { + derived.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) }); + } + if (body.appearance.equilibriumTemperatureK !== null) { + derived.push({ label: 'Equilibrium temp.', value: formatTemperature(body.appearance.equilibriumTemperatureK) }); + } + if (body.appearance.bulkDensityGramsPerCm3 !== null) { + derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) }); + } + + return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) }; +} + +/** + * The derived surface is a reasoned illustration, and a panel of real measurements sitting next + * to it is exactly the context in which it could be mistaken for another one. + */ +function provenanceFor(body: BodyDetailViewModel): string { + if (body.hasPhotography) { + return 'Surface: NASA/ESA/USGS photography.'; + } + return body.appearance.equilibriumTemperatureK === null + ? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.' + : 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.'; +} diff --git a/src/app/features/body-detail/body-view-model.spec.ts b/src/app/features/body-detail/body-view-model.spec.ts new file mode 100644 index 0000000..a76f661 --- /dev/null +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -0,0 +1,124 @@ +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'; + +const orbit = (overrides: Partial = {}): OrbitalElements => ({ + semiMajorAxisAu: 1, + eccentricity: 0.0167, + inclinationDeg: 0, + longitudeOfAscendingNodeDeg: 0, + argumentOfPeriapsisDeg: 0, + meanAnomalyAtEpochDeg: 0, + epochJd: 2451545, + ...overrides, +}); + +const sun: StarRecord = { + id: SUN_STAR_ID, + name: 'Sol', + x: 0, + y: 0, + z: 0, + magnitude: -26.7, + spectralType: 'G2V', + colorIndex: 0.65, +}; + +const earth: BodyRecord = { + id: 'earth', + systemStarId: SUN_STAR_ID, + name: 'Earth', + kind: 'planet', + radiusKm: 6371, + orbit: orbit(), +}; +const luna: BodyRecord = { + id: 'luna', + systemStarId: SUN_STAR_ID, + name: 'Moon', + kind: 'moon', + radiusKm: 1737, + parentBodyId: 'earth', + orbit: orbit({ semiMajorAxisAu: 0.00257 }), +}; + +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', () => { + const model = buildBodyViewModel('earth', catalogues); + expect(model?.orbitalPeriodSource).toBe('derived'); + expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1); + }); + + it('leaves a moon without a period rather than inventing one', () => { + const model = buildBodyViewModel('luna', catalogues); + expect(model?.orbitalPeriodDays).toBeUndefined(); + expect(model?.orbitalPeriodSource).toBeUndefined(); + }); + + it('marks a published exoplanet period as measured, not derived', () => { + const exoplanet: ExoplanetRecord = { + id: 'kepler-22-b', + hostStarId: null, + hostStarName: 'Kepler-22', + name: 'Kepler-22 b', + periodDays: 289.9, + orbit: { semiMajorAxisAu: 0.849 }, + }; + const model = buildBodyViewModel('kepler-22-b', { + bodies: [], + exoplanets: [exoplanet], + stars: [], + }); + expect(model?.orbitalPeriodSource).toBe('measured'); + expect(model?.orbitalPeriodDays).toBe(289.9); + }); + + it('leaves an exoplanet with no published period undefined rather than assuming a solar-mass host', () => { + const exoplanet: ExoplanetRecord = { + id: 'x', + hostStarId: null, + hostStarName: 'X', + name: 'X b', + orbit: { semiMajorAxisAu: 0.05 }, + }; + const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [] }); + expect(model?.orbitalPeriodDays).toBeUndefined(); + }); + + it('returns undefined for an id in neither catalogue', () => { + expect(buildBodyViewModel('nowhere', catalogues)).toBeUndefined(); + }); + + it('carries the host star id, so callers need not rescan the catalogues for it', () => { + expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID); + }); +}); diff --git a/src/app/features/body-detail/body-view-model.ts b/src/app/features/body-detail/body-view-model.ts new file mode 100644 index 0000000..becca17 --- /dev/null +++ b/src/app/features/body-detail/body-view-model.ts @@ -0,0 +1,99 @@ +import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance'; +import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance'; +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 { BodyDetailViewModel } from './body-detail.model'; + +/** Everything the view model is assembled from — the three catalogues, already loaded. */ +export interface BodyCatalogues { + readonly bodies: readonly BodyRecord[]; + readonly exoplanets: readonly ExoplanetRecord[]; + readonly stars: readonly StarRecord[]; +} + +/** + * Bolometric luminosity of a star in solar units, from what the catalogue measured: apparent + * magnitude, parallax distance, and a bolometric correction read off the spectral type. + */ +export function luminosityOf(star: StarRecord | undefined): number | null { + if (!star) { + return null; + } + return luminositySolar({ + magnitude: star.magnitude, + distancePc: Math.hypot(star.x, star.y, star.z), + spectralType: star.spectralType, + }); +} + +/** + * Builds the flattened view model for one body or exoplanet. + * + * Shared rather than duplicated per surface: the in-map card and the full detail page show + * overlapping subsets of the same quantities, and two independent assemblies of "what do we + * know about this world" is exactly the shape of bug where a planet reads 255 K in one panel + * and 254 K in the other. + */ +export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): BodyDetailViewModel | undefined { + 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); + return { + id: body.id, + name: body.name, + kind: body.kind, + hostStarName: hostStar?.name ?? 'Unknown star', + hostStarId: body.systemStarId, + radiusKm: body.radiusKm, + orbit: body.orbit, + appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)), + hasPhotography: bodyTexturePath(body.id) !== undefined, + orbitalPeriodDays: periodDays, + orbitalPeriodSource: periodDays === undefined ? undefined : 'derived', + }; + } + + const exoplanet = catalogues.exoplanets.find((candidate) => candidate.id === id); + if (!exoplanet) { + return undefined; + } + const hostStar = catalogues.stars.find((star) => star.id === exoplanet.hostStarId); + return { + id: exoplanet.id, + name: exoplanet.name, + kind: 'exoplanet', + hostStarName: exoplanet.hostStarName, + hostStarId: exoplanet.hostStarId ?? undefined, + radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined, + massEarth: exoplanet.massEarth, + discoveryYear: exoplanet.discoveryYear, + orbit: exoplanet.orbit, + appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)), + hasPhotography: bodyTexturePath(exoplanet.id) !== undefined, + // `periodDays` is populated for none of the shipped records, and deriving one would need the + // host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass + // host, which would silently mis-state the period of every planet around an M dwarf. + orbitalPeriodDays: exoplanet.periodDays, + 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/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 21e8ef8..1b8c54b 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -1,21 +1,16 @@ -import { DecimalPipe } from '@angular/common'; -import { Component, input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; import { Router } from '@angular/router'; -import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance'; +import { bodyReadouts } from './body-readouts'; import { BodyDetailViewModel } from './body-detail.model'; -const KIND_LABELS: Record = { - planet: 'Planet', - moon: 'Moon', - dwarf: 'Dwarf planet', - exoplanet: 'Exoplanet' -}; - /** * Displays the real NASA data for the currently selected body/exoplanet: kind, physical * size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only — * `BodyDetailSceneComponent` supplies the view model and owns navigation state. + * + * The rows come from `bodyReadouts`, shared with the system view's object card so the same body + * cannot read differently in the two places it can be inspected. */ @Component({ selector: 'app-info-panel', @@ -33,82 +28,37 @@ const KIND_LABELS: Record = {

{{ body().name }}

-

{{ kindLabel() }} · {{ body().hostStarName }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

-
- @if (body().radiusKm) { -
Radius
-
{{ body().radiusKm | number: '1.0-1' }} km
- } - @if (body().massEarth) { -
Mass
-
{{ body().massEarth | number: '1.0-2' }} Earth masses
- } - @if (body().orbit.semiMajorAxisAu) { -
Semi-major axis
-
{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU
- } - @if (body().orbit.eccentricity !== undefined) { -
Eccentricity
-
{{ body().orbit.eccentricity | number: '1.0-4' }}
- } - @if (body().orbit.inclinationDeg !== undefined) { -
Inclination
-
{{ body().orbit.inclinationDeg | number: '1.0-2' }}°
- } - @if (body().discoveryYear) { -
Discovered
-
{{ body().discoveryYear }}
- } -
+ @if (readouts().measured.length) { +

Measured

+
+ @for (row of readouts().measured; track row.label) { +
{{ row.label }}
+
{{ row.value }}
+ } +
+ }

Derived

-
Class
-
{{ classLabel() }}
- @if (body().appearance.equilibriumTemperatureK !== null) { -
Equilibrium temp.
-
{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} K
- } - @if (body().appearance.bulkDensityGramsPerCm3 !== null) { -
Bulk density
-
{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} g/cm³
+ @for (row of readouts().derived; track row.label) { +
{{ row.label }}
+
{{ row.value }}
}
-

{{ surfaceProvenance() }}

+

{{ readouts().provenance }}

- `, - imports: [DecimalPipe] + ` }) export class InfoPanelComponent { readonly body = input.required(); + readonly readouts = computed(() => bodyReadouts(this.body())); + constructor(private readonly router: Router) {} - kindLabel(): string { - return KIND_LABELS[this.body().kind]; - } - - classLabel(): string { - return PLANET_CLASS_LABELS[this.body().appearance.planetClass]; - } - - /** - * Says plainly which of the two the viewer is looking at. The derived surface is a reasoned - * illustration, and a panel of real measurements sitting next to it is exactly the context in - * which it could be mistaken for another one. - */ - surfaceProvenance(): string { - if (this.body().hasPhotography) { - return 'Surface: NASA/ESA/USGS photography.'; - } - const temperature = this.body().appearance.equilibriumTemperatureK; - return temperature === null - ? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.' - : 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.'; - } - goBack(): void { void this.router.navigate(['/']); } 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 12b0027..992f76d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -5,7 +5,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { dateToJulianDate } from '../../shared/astro/constants'; import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy'; -import { luminositySolar } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; @@ -20,7 +19,11 @@ import { DeepSkyRenderer } from './deep-sky-renderer'; import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; import { MilkyWayRenderer } from './milky-way-renderer'; import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; +import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity'; +import { BodyDetailViewModel } from '../body-detail/body-detail.model'; +import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model'; import { HudReadout, StarmapHudComponent } from './starmap-hud.component'; +import { SystemObjectCardComponent } from './system-object-card.component'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; import { SystemOrbitsRenderer } from './system-orbits-renderer'; @@ -125,15 +128,6 @@ const EXIT_DURATION_SECONDS = 0.9; const RETURN_DURATION_SECONDS = 1.1; const GALACTIC_FLIGHT_SECONDS = 2.4; -/** Camera range for the readout panel, in the unit that suits the distance. */ -function formatParsecs(distancePc: number): string { - return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`; -} - -function formatAu(distanceAu: number): string { - return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`; -} - /** * Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking * at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the @@ -157,7 +151,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector @Component({ selector: 'app-galaxy-system-scene', providers: [EngineService], - imports: [StarmapHudComponent], + imports: [StarmapHudComponent, SystemObjectCardComponent], template: `
@@ -172,6 +166,9 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [range]="hudRange()" (levelSelected)="goToLevel($event)" /> + @if (objectCard(); as card) { + + }
` }) @@ -194,6 +191,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { readonly hudNote = signal(''); readonly hudRange = signal(''); + /** + * The body whose card is showing: whichever is pinned by a click, else whatever the pointer is + * over. Undefined outside the system view, and cleared when the view leaves one. + */ + readonly objectCard = signal(undefined); + private enterableSystems = 0; + private pinnedBodyId: string | null = null; + private hoveredBodyId: string | null = null; + private controls?: OrbitControls; private rig?: CameraRigController; private starField?: StarFieldRenderer; @@ -251,6 +257,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.resizeObserver?.disconnect(); this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown); this.canvasRef().nativeElement.removeEventListener('click', this.handleClick); + this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove); this.controls?.dispose(); this.starField?.dispose(); this.deepSky?.dispose(); @@ -341,6 +348,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.starsById = new Map(stars.map((star) => [star.id, star])); this.bodies = bodies; this.exoplanets = exoplanets; + // Which stars can be flown into: those with catalogued bodies of their own, plus the Sun. + this.enterableSystems = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter((id) => id !== null)).size; // Built once rather than per label refresh: it is a scan of every body and exoplanet, and the // labels are recomputed whenever the camera moves. this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter( @@ -389,6 +398,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { canvas.addEventListener('pointerdown', this.handlePointerDown); canvas.addEventListener('click', this.handleClick); + canvas.addEventListener('pointermove', this.handlePointerMove); this.observeResize(canvas); this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds)); @@ -628,13 +638,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { if (this.systemGroup.visible && star) { const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length; + const moonCount = this.bodies.filter((body) => body.systemStarId === star.id && body.parentBodyId).length; + const distancePc = Math.hypot(star.x, star.y, star.z); + const luminosity = luminosityOf(star); this.hudEyebrow.set('System'); this.hudTitle.set(star.name); this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : ''); this.hudReadouts.set([ - { label: 'Bodies', value: `${planetCount}` }, - { label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` }, - { label: 'Magnitude', value: star.magnitude.toFixed(2) } + { label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}` }, + // Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right + // and reads as a bug: the distance from here to here is not a measurement. + ...(distancePc > 0 ? [{ label: 'Distance', value: formatParsecs(distancePc) }] : []), + { label: 'Magnitude', value: star.magnitude.toFixed(2) }, + ...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []) ]); this.hudNote.set('Orbits propagated from published elements to the current date.'); this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET))); @@ -665,7 +681,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // what it draws. See `STAR_RENDER_BUDGET`. { label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` }, { label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` }, - { label: 'Exoplanets', value: `${this.exoplanets.length}` } + { label: 'Exoplanets', value: `${this.exoplanets.length}` }, + // 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.'); } @@ -714,16 +732,76 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } } + /** + * Picks a body in the system view. Clicking one pins its card; clicking empty space unpins, + * which is also how the card is dismissed without aiming for its close control. + * + * This used to navigate straight to `/body/:id`. That tore down the system scene and the camera + * with it, so comparing two planets meant flying back into the system between each — the card + * shows the same numbers over the live view instead, and `Full view` still opens the route. + */ private handleSystemClick(): void { if (!this.systemRenderer) { return; } const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects); const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined; + + this.pinnedBodyId = member ? member.id : null; if (member) { this.navigationStore.selectBody(member.id); - void this.router.navigate(['/body', member.id]); } + this.refreshObjectCard(); + } + + /** + * Hover preview, so a body's figures can be read without committing a click. + * + * The raycast is against the system's own handful of pickable meshes rather than the star field, + * so it stays cheap even on a software rasterizer — it is the rendering that is slow in that + * environment, not the picking. Skipped outside the system view and during a camera flight. + */ + private readonly handlePointerMove = (event: PointerEvent): void => { + if (!this.systemRenderer || !this.systemGroup.visible || this.rig?.isAnimating) { + return; + } + const canvas = this.canvasRef().nativeElement; + const rect = canvas.getBoundingClientRect(); + const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); + this.raycaster.setFromCamera(pointerNdc, this.engine.getCamera()); + + const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects); + const hoveredId = (hit ? this.systemRenderer.memberForObject(hit.object) : undefined)?.id ?? null; + if (hoveredId === this.hoveredBodyId) { + return; + } + this.hoveredBodyId = hoveredId; + canvas.style.cursor = hoveredId ? 'pointer' : ''; + this.refreshObjectCard(); + }; + + /** A pinned body wins over a hovered one, so the card does not change under the pointer. */ + private refreshObjectCard(): void { + const id = this.pinnedBodyId ?? this.hoveredBodyId; + this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars })); + } + + /** Clears the card and everything that would bring it straight back. */ + private clearObjectCard(): void { + this.pinnedBodyId = null; + this.hoveredBodyId = null; + this.objectCard.set(undefined); + this.canvasRef().nativeElement.style.cursor = ''; + } + + dismissObjectCard(): void { + this.clearObjectCard(); + } + + /** The deliberate step out to the dedicated route, from the card's own control. */ + openObjectDetail(id: string): void { + this.navigationStore.selectBody(id); + void this.router.navigate(['/body', id]); } /** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */ @@ -797,7 +875,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // exoplanets, so it has no meaningful direction and the renderer falls back. // The star's luminosity, derived from its own catalogued magnitude and distance, is what // decides how hot each body in the system is — and so what each of them looks like. - const hostLuminosity = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType }); + const hostLuminosity = luminosityOf(star); this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemGroup.add(this.systemRenderer.object); @@ -881,6 +959,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemGroup.visible = false; this.galaxyGroup.visible = true; + // The bodies it described are no longer on screen, and a stale pin would otherwise survive + // into the next system entered. + this.clearObjectCard(); camera.near = GALAXY_NEAR_PC; camera.far = GALAXY_FAR_PC; diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index 2f6e3ff..05ed926 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -5,6 +5,12 @@ import { ViewLevel } from '../../shared/state/navigation.store'; export interface HudReadout { readonly label: string; readonly value: string; + /** + * True when the figure was computed from other measurements rather than catalogued directly. + * Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for + * an observed one. + */ + readonly derived?: boolean; } interface LadderStep { @@ -100,14 +106,14 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@for (readout of readouts(); track readout.label) {
-
{{ readout.label }}
+
{{ readout.label }}@if (readout.derived) {}
{{ readout.value }}
}
} - @if (note()) { -

{{ note() }}

+ @if (note() || hasDerived()) { +

@if (hasDerived()) {* Derived, not catalogued. }{{ note() }}

} @@ -127,6 +133,9 @@ export class StarmapHudComponent { readonly readouts = input([]); /** Standing caveat for the current view, e.g. that galactic structure is a model. */ readonly note = input(''); + /** Whether any readout needs the derived-value footnote. */ + readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived)); + /** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */ readonly range = input(''); readonly showReticle = input(true); diff --git a/src/app/features/galaxy-system/system-object-card.component.spec.ts b/src/app/features/galaxy-system/system-object-card.component.spec.ts new file mode 100644 index 0000000..fcd56d9 --- /dev/null +++ b/src/app/features/galaxy-system/system-object-card.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { BodyDetailViewModel } from '../body-detail/body-detail.model'; +import { PlanetAppearance } from '../../shared/astro/planet-appearance'; +import { SystemObjectCardComponent } from './system-object-card.component'; + +const appearance = (overrides: Partial = {}): PlanetAppearance => + ({ + planetClass: 'temperate', + palette: { structure: 'mottled' }, + equilibriumTemperatureK: 255, + bulkDensityGramsPerCm3: 5.51, + polarCapExtentDeg: 25, + seed: 1, + ...overrides, + }) as PlanetAppearance; + +const earth: BodyDetailViewModel = { + id: 'earth', + name: 'Earth', + kind: 'planet', + hostStarName: 'Sol', + radiusKm: 6371, + orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167 }, + appearance: appearance(), + hasPhotography: true, + orbitalPeriodDays: 365.25, + orbitalPeriodSource: 'derived', +}; + +describe('SystemObjectCardComponent', () => { + let fixture: ComponentFixture; + + function render(body: BodyDetailViewModel): HTMLElement { + fixture.componentRef.setInput('body', body); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + /** The label/value pairs under one of the two headings, in order. */ + function block(host: HTMLElement, heading: 'Measured' | 'Derived'): string[] { + const headings = [...host.querySelectorAll('p')].filter((p) => p.textContent?.trim() === heading); + const list = headings[0]?.nextElementSibling; + return list ? [...list.querySelectorAll('dt')].map((dt) => dt.textContent?.trim() ?? '') : []; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SystemObjectCardComponent], + }).compileComponents(); + fixture = TestBed.createComponent(SystemObjectCardComponent); + }); + + it('files a period computed from the semi-major axis under Derived', () => { + const host = render(earth); + expect(block(host, 'Derived')).toContain('Period'); + expect(block(host, 'Measured')).not.toContain('Period'); + }); + + it('files a published period under Measured instead', () => { + // Same field, opposite heading — the provenance flag is what decides, not the field's name. + const host = render({ ...earth, orbitalPeriodSource: 'measured' }); + expect(block(host, 'Measured')).toContain('Period'); + expect(block(host, 'Derived')).not.toContain('Period'); + }); + + it('omits the period entirely when there is none to show', () => { + const host = render({ ...earth, orbitalPeriodDays: undefined, orbitalPeriodSource: undefined }); + expect(block(host, 'Measured')).not.toContain('Period'); + expect(block(host, 'Derived')).not.toContain('Period'); + }); + + it('never shows an empty Measured block', () => { + const bare: BodyDetailViewModel = { + ...earth, + radiusKm: undefined, + orbit: {}, + orbitalPeriodDays: undefined, + orbitalPeriodSource: undefined, + discoveryYear: undefined, + }; + expect(render(bare).textContent).not.toContain('Measured'); + }); + + it('says a photographed surface is a photograph', () => { + expect(render(earth).textContent).toContain('photography'); + }); + + it('says an illustrated surface is not an observation', () => { + expect(render({ ...earth, hasPhotography: false }).textContent).toContain('Not an observation'); + }); + + it('explains a missing temperature rather than leaving the row blank', () => { + const host = render({ + ...earth, + hasPhotography: false, + appearance: appearance({ equilibriumTemperatureK: null }), + }); + expect(block(host, 'Derived')).not.toContain('Equilibrium temp.'); + expect(host.textContent).toContain('host star is not in the catalogue'); + }); + + it('emits rather than navigating, so the scene decides what selection means', () => { + const host = render(earth); + let opened = 0; + let dismissed = 0; + fixture.componentInstance.openRequested.subscribe(() => (opened += 1)); + fixture.componentInstance.dismissed.subscribe(() => (dismissed += 1)); + + host.querySelector('button[aria-label="Close"]')!.click(); + [...host.querySelectorAll('button')].find((button) => button.textContent?.includes('Full view'))!.click(); + + expect({ opened, dismissed }).toEqual({ opened: 1, dismissed: 1 }); + }); +}); diff --git a/src/app/features/galaxy-system/system-object-card.component.ts b/src/app/features/galaxy-system/system-object-card.component.ts new file mode 100644 index 0000000..98cb79b --- /dev/null +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -0,0 +1,88 @@ +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { BodyDetailViewModel } from '../body-detail/body-detail.model'; +import { bodyReadouts } from '../body-detail/body-readouts'; + +/** + * The card shown for a body picked in the system view, without leaving it. + * + * Selecting a planet used to navigate straight to `/body/:id`, which tore down the system scene + * and the camera position with it — so comparing two planets meant flying back in twice. This + * shows the same numbers over the live view, and keeps the route as the deliberate step for the + * full 3D inspection. + * + * The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only. + */ +@Component({ + selector: 'app-system-object-card', + changeDetection: ChangeDetectionStrategy.OnPush, + // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against + // the scene rather than against whatever box the inline default would have left it in — which + // put the card off the bottom-left corner of the viewport entirely. + host: { class: 'pointer-events-none absolute inset-0 block' }, + template: ` + +
+
+
+
+

{{ body().name }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+
+ +
+ + @if (readouts().measured.length) { +

Measured

+
+ @for (row of readouts().measured; track row.label) { +
{{ row.label }}
+
{{ row.value }}
+ } +
+ } + +

Derived

+
+ @for (row of readouts().derived; track row.label) { +
{{ row.label }}
+
{{ row.value }}
+ } +
+ +

{{ readouts().provenance }}

+ + +
+
+ ` +}) +export class SystemObjectCardComponent { + readonly body = input.required(); + /** The close control, and anything else that should dismiss the card. */ + readonly dismissed = output(); + /** Request for the full `/body/:id` route. */ + readonly openRequested = output(); + + readonly readouts = computed(() => bodyReadouts(this.body())); +} diff --git a/src/app/shared/format/quantity.spec.ts b/src/app/shared/format/quantity.spec.ts new file mode 100644 index 0000000..3195cdb --- /dev/null +++ b/src/app/shared/format/quantity.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { formatAu, formatDensity, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity'; + +describe('formatParsecs', () => { + it('switches to kiloparsecs past a thousand parsecs', () => { + expect(formatParsecs(8180)).toBe('8.2 kpc'); + }); + + it('keeps two decimals only while they carry information', () => { + expect(formatParsecs(1.3)).toBe('1.30 pc'); + expect(formatParsecs(250)).toBe('250 pc'); + }); +}); + +describe('formatAu', () => { + it('holds four decimals for close-in orbits', () => { + // 0.0026 AU is a real published semi-major axis; two decimals would render it — and every + // other hot Jupiter — as a flat 0.00. + expect(formatAu(0.0026)).toBe('0.0026 AU'); + }); + + it('drops to whole units past 100 AU', () => { + expect(formatAu(120)).toBe('120 AU'); + expect(formatAu(5.53)).toBe('5.53 AU'); + }); +}); + +describe('formatPeriod', () => { + it('uses hours below a day', () => { + expect(formatPeriod(0.5)).toBe('12.0 h'); + }); + + it('uses days through the short end and years beyond', () => { + expect(formatPeriod(88)).toBe('88.0 d'); + expect(formatPeriod(365.25)).toBe('365.3 d'); + expect(formatPeriod(4332.6)).toBe('11.9 yr'); + }); + + it('drops the decimal for periods of centuries', () => { + expect(formatPeriod(90_560)).toBe('248 yr'); + }); +}); + +describe('formatRadiusKm', () => { + it('groups thousands on both sides of the decimal threshold', () => { + // Not just above it: 69,911 km beside a bare 6371 km reads as two different conventions. + expect(formatRadiusKm(69_911)).toBe('69,911 km'); + expect(formatRadiusKm(6371)).toBe('6,371 km'); + }); + + it('keeps a decimal only for small bodies', () => { + expect(formatRadiusKm(11.3)).toBe('11.3 km'); + expect(formatRadiusKm(1737)).toBe('1,737 km'); + }); +}); + +describe('formatMassEarth', () => { + it('scales precision to magnitude', () => { + expect(formatMassEarth(0.815)).toBe('0.815 M⊕'); + expect(formatMassEarth(17.15)).toBe('17.15 M⊕'); + expect(formatMassEarth(317.8)).toBe('318 M⊕'); + }); +}); + +describe('formatTemperature and formatDensity', () => { + it('rounds temperature to a whole kelvin', () => { + expect(formatTemperature(254.6)).toBe('255 K'); + }); + + it('holds two decimals of density', () => { + expect(formatDensity(5.514)).toBe('5.51 g/cm³'); + }); +}); + +describe('formatLuminosity', () => { + it('stays decimal across the ordinary range', () => { + expect(formatLuminosity(1)).toBe('1.00 L☉'); + expect(formatLuminosity(0.0017)).toBe('0.002 L☉'); + }); + + it('goes to powers of ten at the extremes', () => { + expect(formatLuminosity(126_000)).toBe('1.3×10⁵ L☉'); + expect(formatLuminosity(0.00004)).toBe('4.0×10⁻⁵ L☉'); + }); +}); diff --git a/src/app/shared/format/quantity.ts b/src/app/shared/format/quantity.ts new file mode 100644 index 0000000..3ba8403 --- /dev/null +++ b/src/app/shared/format/quantity.ts @@ -0,0 +1,76 @@ +/** + * Formatting for every physical quantity the HUD and the body cards display. + * + * Shared rather than per-component so the same measurement reads identically wherever it + * appears. The rule throughout is that precision follows magnitude — a figure is shown to the + * digits that carry information at its own scale, not to a fixed decimal count that reads as + * false precision at one end (`0.00 pc`) and loses real information at the other (`26000 pc`). + */ + +/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */ +export function formatParsecs(distancePc: number): string { + return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`; +} + +/** Distance in astronomical units, for anything inside a system. */ +export function formatAu(distanceAu: number): string { + if (distanceAu < 0.01) { + // Close-in exoplanets: 0.0026 AU is a real, published semi-major axis, and two decimals + // would round every hot Jupiter in the catalogue to the same `0.00`. + return `${distanceAu.toFixed(4)} AU`; + } + return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`; +} + +/** + * Orbital period in whichever unit reads naturally at its length — hours for the very short + * periods common among hot Jupiters, days up to a couple of years, then years. + */ +export function formatPeriod(days: number): string { + if (days < 1) { + return `${(days * 24).toFixed(1)} h`; + } + if (days < 700) { + return `${days.toFixed(days < 10 ? 2 : 1)} d`; + } + return `${(days / 365.25).toFixed(days / 365.25 < 100 ? 1 : 0)} yr`; +} + +/** Radius in kilometres. */ +export function formatRadiusKm(radiusKm: number): string { + // Grouped on both sides of the decimal threshold: 69,911 km beside a bare 6371 km reads as two + // different conventions rather than one. + const digits = radiusKm < 100 ? 1 : 0; + return `${radiusKm.toLocaleString('en-GB', { minimumFractionDigits: digits, maximumFractionDigits: digits })} km`; +} + +/** Mass in Earth masses. */ +export function formatMassEarth(massEarth: number): string { + if (massEarth >= 100) { + return `${massEarth.toFixed(0)} M⊕`; + } + return `${massEarth.toFixed(massEarth < 1 ? 3 : 2)} M⊕`; +} + +/** Equilibrium temperature. Always a whole kelvin — the model is not good to a fraction of one. */ +export function formatTemperature(kelvin: number): string { + return `${Math.round(kelvin)} K`; +} + +/** Bulk density. */ +export function formatDensity(gramsPerCm3: number): string { + return `${gramsPerCm3.toFixed(2)} g/cm³`; +} + +/** Bolometric luminosity in solar units, which spans many orders of magnitude. */ +export function formatLuminosity(solar: number): string { + if (solar >= 1000 || (solar > 0 && solar < 0.001)) { + const exponent = Math.floor(Math.log10(solar)); + return `${(solar / Math.pow(10, exponent)).toFixed(1)}×10${superscript(exponent)} L☉`; + } + return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`; +} + +function superscript(value: number): string { + return `${value}`.replace('-', '⁻').replace(/\d/g, (digit) => '⁰¹²³⁴⁵⁶⁷⁸⁹'[Number(digit)]); +} diff --git a/src/app/shared/models/readout.ts b/src/app/shared/models/readout.ts new file mode 100644 index 0000000..db01b19 --- /dev/null +++ b/src/app/shared/models/readout.ts @@ -0,0 +1,5 @@ +/** A labelled quantity ready for display — the shape every panel in the app renders. */ +export interface Readout { + readonly label: string; + readonly value: string; +}