From 1019727a39c470497d73355dce2f9f598627d7de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:45:17 +0000 Subject: [PATCH 1/5] Say what is known about a world, and how it is known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes to the readout panel and the body cards, which between them were showing less than the catalogues hold and not always distinguishing a measurement from an inference. Picking a planet 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. Hovering a body now raises a card over the live view and clicking pins it; Full view still opens the route for the full 3D inspection. Clicking empty space unpins, and leaving the system clears it. The card and the detail page were assembling "what do we know about this world" independently, which is the shape of bug where a planet reads 255 K in one panel and 254 K in the other. Both now build from one shared view model. Orbital period was absent everywhere. For a heliocentric orbit it follows exactly from the semi-major axis, because in these units the Sun's mass is the unit of mass — Mars comes back 687.0 d against a published 686.98. It is deliberately not computed for moons, whose elements are relative to a parent planet the catalogue has no mass for, nor for exoplanets: periodDays is populated for none of the 6319 shipped records and hostStarMassSolar for none either, so any figure would assume a solar-mass host and mis-state every planet around an M dwarf. Where a period does exist it is filed under Measured or Derived according to which it is, not by its field name. The system readout showed a flat 0.00 pc for the Sun's distance, which is arithmetically right and reads as a bug — the distance from here to here is not a measurement, so it is suppressed. It gains the host star's luminosity, marked as derived, and counts moons separately from planets. The neighbourhood readout gains the one thing the star field cannot show: how many of those points can actually be entered. Derived readouts carry a marker and a footnote saying so. Every quantity now formats through one module whose precision follows magnitude, rather than a fixed decimal count per call site that read as false precision at one end and lost real information at the other: 0.0026 AU stays legible instead of rounding to 0.00, and Pluto's period reads 248 yr rather than 90560 d. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- .../body-detail-scene.component.ts | 64 ++----- .../features/body-detail/body-detail.model.ts | 12 ++ .../body-detail/body-view-model.spec.ts | 125 +++++++++++++ .../features/body-detail/body-view-model.ts | 96 ++++++++++ .../body-detail/info-panel.component.ts | 46 +++-- .../galaxy-system-scene.component.ts | 131 ++++++++++++-- .../galaxy-system/starmap-hud.component.ts | 15 +- .../system-object-card.component.spec.ts | 116 ++++++++++++ .../system-object-card.component.ts | 165 ++++++++++++++++++ src/app/shared/format/quantity.spec.ts | 84 +++++++++ src/app/shared/format/quantity.ts | 80 +++++++++ 11 files changed, 856 insertions(+), 78 deletions(-) create mode 100644 src/app/features/body-detail/body-view-model.spec.ts create mode 100644 src/app/features/body-detail/body-view-model.ts create mode 100644 src/app/features/galaxy-system/system-object-card.component.spec.ts create mode 100644 src/app/features/galaxy-system/system-object-card.component.ts create mode 100644 src/app/shared/format/quantity.spec.ts create mode 100644 src/app/shared/format/quantity.ts 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..2b8af20 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,24 @@ 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); + + const body = this.bodies.find((candidate) => candidate.id === id); + if (body) { + this.navigationStore.selectStar(body.systemStarId); + } else { + const exoplanet = this.exoplanets.find((candidate) => candidate.id === id); + if (exoplanet?.hostStarId != null) { + this.navigationStore.selectStar(exoplanet.hostStarId); + } + } this.notFound.set(false); this.navigationStore.selectBody(id); @@ -164,18 +142,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..0e63ff8 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -26,4 +26,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-view-model.spec.ts b/src/app/features/body-detail/body-view-model.spec.ts new file mode 100644 index 0000000..1db871a --- /dev/null +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -0,0 +1,125 @@ +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('reads the same body identically however it is reached', () => { + // The whole point of the shared builder: the card and the detail route must not drift. + expect(buildBodyViewModel('earth', catalogues)).toEqual(buildBodyViewModel('earth', catalogues)); + }); +}); 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..4d30650 --- /dev/null +++ b/src/app/features/body-detail/body-view-model.ts @@ -0,0 +1,96 @@ +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); + return { + id: body.id, + name: body.name, + kind: body.kind, + hostStarName: hostStar?.name ?? 'Unknown star', + radiusKm: body.radiusKm, + orbit: body.orbit, + appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)), + hasPhotography: bodyTexturePath(body.id) !== undefined, + orbitalPeriodDays: heliocentricPeriodDays(body), + orbitalPeriodSource: heliocentricPeriodDays(body) === 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, + 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..8ce2311 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -1,8 +1,8 @@ -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 { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity'; import { BodyDetailViewModel } from './body-detail.model'; const KIND_LABELS: Record = { @@ -35,30 +35,35 @@ const KIND_LABELS: Record = {

{{ body().name }}

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

+

Measured

@if (body().radiusKm) {
Radius
-
{{ body().radiusKm | number: '1.0-1' }} km
+
{{ radius() }}
} @if (body().massEarth) {
Mass
-
{{ body().massEarth | number: '1.0-2' }} Earth masses
+
{{ mass() }}
} @if (body().orbit.semiMajorAxisAu) {
Semi-major axis
-
{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU
+
{{ semiMajorAxis() }}
} @if (body().orbit.eccentricity !== undefined) {
Eccentricity
-
{{ body().orbit.eccentricity | number: '1.0-4' }}
+
{{ body().orbit.eccentricity!.toFixed(4) }}
} @if (body().orbit.inclinationDeg !== undefined) {
Inclination
-
{{ body().orbit.inclinationDeg | number: '1.0-2' }}°
+
{{ body().orbit.inclinationDeg!.toFixed(2) }}°
+ } + @if (measuredPeriod()) { +
Period
+
{{ measuredPeriod() }}
} @if (body().discoveryYear) {
Discovered
-
{{ body().discoveryYear }}
+
{{ body().discoveryYear }}
}
@@ -66,24 +71,41 @@ const KIND_LABELS: Record = {
Class
{{ classLabel() }}
+ @if (derivedPeriod()) { +
Period
+
{{ derivedPeriod() }}
+ } @if (body().appearance.equilibriumTemperatureK !== null) {
Equilibrium temp.
-
{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} K
+
{{ temperature() }}
} @if (body().appearance.bulkDensityGramsPerCm3 !== null) {
Bulk density
-
{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} g/cm³
+
{{ density() }}
}

{{ surfaceProvenance() }}

- `, - imports: [DecimalPipe] + ` }) export class InfoPanelComponent { readonly body = input.required(); + readonly radius = computed(() => formatRadiusKm(this.body().radiusKm!)); + readonly mass = computed(() => formatMassEarth(this.body().massEarth!)); + readonly semiMajorAxis = computed(() => formatAu(this.body().orbit.semiMajorAxisAu!)); + readonly temperature = computed(() => formatTemperature(this.body().appearance.equilibriumTemperatureK!)); + readonly density = computed(() => formatDensity(this.body().appearance.bulkDensityGramsPerCm3!)); + /** The period goes under whichever heading its provenance calls for, and nowhere otherwise. */ + readonly measuredPeriod = computed(() => this.periodUnder('measured')); + readonly derivedPeriod = computed(() => this.periodUnder('derived')); + + private periodUnder(source: 'measured' | 'derived'): string | null { + const body = this.body(); + return body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === source ? formatPeriod(body.orbitalPeriodDays) : null; + } + constructor(private readonly router: Router) {} kindLabel(): string { 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..7ec46fb 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'; @@ -126,13 +129,7 @@ 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 @@ -157,7 +154,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 +169,9 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [range]="hudRange()" (levelSelected)="goToLevel($event)" /> + @if (objectCard(); as card) { + + }
` }) @@ -194,6 +194,14 @@ 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 pinnedBodyId: string | null = null; + private hoveredBodyId: string | null = null; + private controls?: OrbitControls; private rig?: CameraRigController; private starField?: StarFieldRenderer; @@ -251,6 +259,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(); @@ -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.enterableSystemCount()}` } ]); this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.'); } @@ -714,16 +732,98 @@ 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]); + } + + /** + * How many stars can actually be entered: those with catalogued bodies of their own, plus the + * Sun. Computed once rather than on every HUD refresh. + */ + private enterableSystems?: number; + + private enterableSystemCount(): number { + if (this.enterableSystems === undefined) { + const hosts = new Set(); + for (const body of this.bodies) { + hosts.add(body.systemStarId); + } + for (const exoplanet of this.exoplanets) { + if (exoplanet.hostStarId !== null) { + hosts.add(exoplanet.hostStarId); + } + } + this.enterableSystems = hosts.size; + } + return this.enterableSystems; } /** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */ @@ -797,7 +897,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 +981,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..4db5fcb --- /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('No host star 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..5cb086f --- /dev/null +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -0,0 +1,165 @@ +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { BodyDetailViewModel } from '../body-detail/body-detail.model'; +import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance'; +import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity'; + +const KIND_LABELS: Record = { + planet: 'Planet', + moon: 'Moon', + dwarf: 'Dwarf planet', + exoplanet: 'Exoplanet', +}; + +interface CardRow { + readonly label: string; + readonly value: string; +} + +/** + * 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. + * + * Measured and derived quantities are kept in separate blocks, each labelled, because the + * difference matters here more than it usually would: no exoplanet has been imaged and several + * of these figures are reasoned rather than observed. 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 }} +

+

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

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

Measured

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

Derived

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

+ {{ 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 kindLabel = computed(() => KIND_LABELS[this.body().kind]); + + /** Only what a catalogue actually published for this body. */ + readonly measured = computed(() => { + const body = this.body(); + const rows: CardRow[] = []; + if (body.radiusKm !== undefined) { + rows.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) }); + } + if (body.massEarth !== undefined) { + rows.push({ label: 'Mass', value: formatMassEarth(body.massEarth) }); + } + if (body.orbit.semiMajorAxisAu !== undefined) { + rows.push({ label: 'Semi-major axis', value: formatAu(body.orbit.semiMajorAxisAu) }); + } + if (body.orbit.eccentricity !== undefined) { + rows.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) }); + } + if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'measured') { + rows.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) }); + } + if (body.discoveryYear !== undefined) { + rows.push({ label: 'Discovered', value: `${body.discoveryYear}` }); + } + return rows; + }); + + /** Everything computed from the measurements above rather than observed directly. */ + readonly derived = computed(() => { + const body = this.body(); + const rows: CardRow[] = [{ label: 'Class', value: PLANET_CLASS_LABELS[body.appearance.planetClass] }]; + if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'derived') { + rows.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) }); + } + if (body.appearance.equilibriumTemperatureK !== null) { + rows.push({ + label: 'Equilibrium temp.', + value: formatTemperature(body.appearance.equilibriumTemperatureK), + }); + } + if (body.appearance.bulkDensityGramsPerCm3 !== null) { + rows.push({ + label: 'Bulk density', + value: formatDensity(body.appearance.bulkDensityGramsPerCm3), + }); + } + return rows; + }); + + /** The same distinction the detail panel draws, stated briefly enough for a card. */ + readonly provenance = computed(() => { + const body = this.body(); + if (body.hasPhotography) { + return 'Surface: NASA/ESA/USGS photography.'; + } + return body.appearance.equilibriumTemperatureK === null + ? 'Surface illustrated from measured size and mass. No host star in the catalogue, so no temperature could be derived.' + : 'Surface illustrated from the measurements above. Not an observation — no image of this world exists.'; + }); +} diff --git a/src/app/shared/format/quantity.spec.ts b/src/app/shared/format/quantity.spec.ts new file mode 100644 index 0000000..2199da3 --- /dev/null +++ b/src/app/shared/format/quantity.spec.ts @@ -0,0 +1,84 @@ +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 for large bodies', () => { + expect(formatRadiusKm(69_911)).toBe('69,911 km'); + }); + + it('keeps a decimal only for small ones', () => { + expect(formatRadiusKm(6371)).toBe('6371 km'); + expect(formatRadiusKm(11.3)).toBe('11.3 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..1f69ab6 --- /dev/null +++ b/src/app/shared/format/quantity.ts @@ -0,0 +1,80 @@ +/** + * 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, or Earth radii once the number stops being legible in km. */ +export function formatRadiusKm(radiusKm: number): string { + return radiusKm >= 10_000 ? `${Math.round(radiusKm).toLocaleString('en-GB')} km` : `${radiusKm.toFixed(radiusKm < 100 ? 1 : 0)} 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☉`; +} + +const SUPERSCRIPTS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']; + +function superscript(value: number): string { + const digits = Math.abs(value) + .toString() + .split('') + .map((digit) => SUPERSCRIPTS[Number(digit)]) + .join(''); + return value < 0 ? `⁻${digits}` : digits; +} From 7b0f32f71aa5d32d32bb742fa1e3f7ec8e42755b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:52:11 +0000 Subject: [PATCH 2/5] Assemble a body's readouts once, not once per panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to review on #3. The card and the detail page each built their own Measured/Derived split, kind label and provenance sentence — the drift buildBodyViewModel exists to prevent, re-forked one layer up, and the drift would have been in which side of the measured/derived line a quantity falls on, which is the distinction those panels exist to draw. One bodyReadouts(body) now returns both blocks and the sentence, and both templates iterate it. The two surfaces render identical rows as a result, and the card gains the inclination the detail page already showed. Also from that review: - KIND_LABELS was duplicated between the two panels; it now lives beside bodyReadouts. The third copy the review pointed at is a different union (search results are star/body/exoplanet, and label a body "Body"), so it stays where it is. - CardRow was HudReadout renamed. Both are now Readout, which HudReadout extends with its derived flag. - The enterable-systems count was a 21-line lazy memo over arrays that are already in hand; it is one expression where those arrays are assigned. - buildBodyViewModel now carries hostStarId, so the detail scene stops rescanning both catalogues for something the builder had already resolved. - heliocentricPeriodDays was called twice for the same body. - The superscript helper was a split/map/join; it is a replace. - info-panel had five computed() each wrapping one pure call with a non-null assertion, beside a template that inlined the same kind of call directly. They are gone with the shared readouts. - Dropped a tautological test that compared a pure function to itself. Replaced with one that asserts the host star id the builder now carries. - Removed the orphaned doc comment left behind when formatParsecs/formatAu moved out. And one the review raised as out of scope but is worth taking: formatRadiusKm grouped thousands above its decimal threshold and not below it, so 69,911 km sat beside a bare 6371 km. Both are grouped now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- .../body-detail-scene.component.ts | 10 +- .../features/body-detail/body-detail.model.ts | 6 + src/app/features/body-detail/body-readouts.ts | 82 +++++++++++++ .../body-detail/body-view-model.spec.ts | 5 +- .../features/body-detail/body-view-model.ts | 7 +- .../body-detail/info-panel.component.ts | 110 +++--------------- .../galaxy-system-scene.component.ts | 30 +---- .../system-object-card.component.spec.ts | 2 +- .../system-object-card.component.ts | 97 ++------------- src/app/shared/format/quantity.spec.ts | 8 +- src/app/shared/format/quantity.ts | 14 +-- src/app/shared/models/readout.ts | 5 + 12 files changed, 146 insertions(+), 230 deletions(-) create mode 100644 src/app/features/body-detail/body-readouts.ts create mode 100644 src/app/shared/models/readout.ts 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 2b8af20..a561593 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -125,14 +125,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { } this.viewModel.set(viewModel); - const body = this.bodies.find((candidate) => candidate.id === id); - if (body) { - this.navigationStore.selectStar(body.systemStarId); - } else { - const exoplanet = this.exoplanets.find((candidate) => candidate.id === id); - if (exoplanet?.hostStarId != null) { - this.navigationStore.selectStar(exoplanet.hostStarId); - } + if (viewModel.hostStarId !== undefined) { + this.navigationStore.selectStar(viewModel.hostStarId); } this.notFound.set(false); diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index 0e63ff8..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; 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 index 1db871a..a76f661 100644 --- a/src/app/features/body-detail/body-view-model.spec.ts +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -118,8 +118,7 @@ describe('buildBodyViewModel', () => { expect(buildBodyViewModel('nowhere', catalogues)).toBeUndefined(); }); - it('reads the same body identically however it is reached', () => { - // The whole point of the shared builder: the card and the detail route must not drift. - expect(buildBodyViewModel('earth', catalogues)).toEqual(buildBodyViewModel('earth', catalogues)); + 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 index 4d30650..becca17 100644 --- a/src/app/features/body-detail/body-view-model.ts +++ b/src/app/features/body-detail/body-view-model.ts @@ -41,17 +41,19 @@ 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); 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: heliocentricPeriodDays(body), - orbitalPeriodSource: heliocentricPeriodDays(body) === undefined ? undefined : 'derived', + orbitalPeriodDays: periodDays, + orbitalPeriodSource: periodDays === undefined ? undefined : 'derived', }; } @@ -65,6 +67,7 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body 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, diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 8ce2311..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 { Component, computed, input } from '@angular/core'; import { Router } from '@angular/router'; -import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance'; -import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity'; +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,104 +28,37 @@ const KIND_LABELS: Record = {

{{ body().name }}

-

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

+

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

-

Measured

-
- @if (body().radiusKm) { -
Radius
-
{{ radius() }}
- } - @if (body().massEarth) { -
Mass
-
{{ mass() }}
- } - @if (body().orbit.semiMajorAxisAu) { -
Semi-major axis
-
{{ semiMajorAxis() }}
- } - @if (body().orbit.eccentricity !== undefined) { -
Eccentricity
-
{{ body().orbit.eccentricity!.toFixed(4) }}
- } - @if (body().orbit.inclinationDeg !== undefined) { -
Inclination
-
{{ body().orbit.inclinationDeg!.toFixed(2) }}°
- } - @if (measuredPeriod()) { -
Period
-
{{ measuredPeriod() }}
- } - @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 (derivedPeriod()) { -
Period
-
{{ derivedPeriod() }}
- } - @if (body().appearance.equilibriumTemperatureK !== null) { -
Equilibrium temp.
-
{{ temperature() }}
- } - @if (body().appearance.bulkDensityGramsPerCm3 !== null) { -
Bulk density
-
{{ density() }}
+ @for (row of readouts().derived; track row.label) { +
{{ row.label }}
+
{{ row.value }}
}
-

{{ surfaceProvenance() }}

+

{{ readouts().provenance }}

` }) export class InfoPanelComponent { readonly body = input.required(); - readonly radius = computed(() => formatRadiusKm(this.body().radiusKm!)); - readonly mass = computed(() => formatMassEarth(this.body().massEarth!)); - readonly semiMajorAxis = computed(() => formatAu(this.body().orbit.semiMajorAxisAu!)); - readonly temperature = computed(() => formatTemperature(this.body().appearance.equilibriumTemperatureK!)); - readonly density = computed(() => formatDensity(this.body().appearance.bulkDensityGramsPerCm3!)); - /** The period goes under whichever heading its provenance calls for, and nowhere otherwise. */ - readonly measuredPeriod = computed(() => this.periodUnder('measured')); - readonly derivedPeriod = computed(() => this.periodUnder('derived')); - - private periodUnder(source: 'measured' | 'derived'): string | null { - const body = this.body(); - return body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === source ? formatPeriod(body.orbitalPeriodDays) : null; - } + 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 7ec46fb..992f76d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -128,9 +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. */ - - /** * 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 @@ -199,6 +196,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * 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; @@ -350,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( @@ -683,7 +683,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { { label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` }, { 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.enterableSystemCount()}` } + { label: 'Systems', value: `${this.enterableSystems}` } ]); this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.'); } @@ -804,28 +804,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { void this.router.navigate(['/body', id]); } - /** - * How many stars can actually be entered: those with catalogued bodies of their own, plus the - * Sun. Computed once rather than on every HUD refresh. - */ - private enterableSystems?: number; - - private enterableSystemCount(): number { - if (this.enterableSystems === undefined) { - const hosts = new Set(); - for (const body of this.bodies) { - hosts.add(body.systemStarId); - } - for (const exoplanet of this.exoplanets) { - if (exoplanet.hostStarId !== null) { - hosts.add(exoplanet.hostStarId); - } - } - this.enterableSystems = hosts.size; - } - return this.enterableSystems; - } - /** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */ private reconcileSelection(selectedStarId: number | null): void { if (this.busy || selectedStarId === this.currentStarId) { 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 index 4db5fcb..fcd56d9 100644 --- a/src/app/features/galaxy-system/system-object-card.component.spec.ts +++ b/src/app/features/galaxy-system/system-object-card.component.spec.ts @@ -98,7 +98,7 @@ describe('SystemObjectCardComponent', () => { appearance: appearance({ equilibriumTemperatureK: null }), }); expect(block(host, 'Derived')).not.toContain('Equilibrium temp.'); - expect(host.textContent).toContain('No host star in the catalogue'); + expect(host.textContent).toContain('host star is not in the catalogue'); }); it('emits rather than navigating, so the scene decides what selection means', () => { diff --git a/src/app/features/galaxy-system/system-object-card.component.ts b/src/app/features/galaxy-system/system-object-card.component.ts index 5cb086f..98cb79b 100644 --- a/src/app/features/galaxy-system/system-object-card.component.ts +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -1,20 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { BodyDetailViewModel } from '../body-detail/body-detail.model'; -import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance'; -import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity'; - -const KIND_LABELS: Record = { - planet: 'Planet', - moon: 'Moon', - dwarf: 'Dwarf planet', - exoplanet: 'Exoplanet', -}; - -interface CardRow { - readonly label: string; - readonly value: string; -} +import { bodyReadouts } from '../body-detail/body-readouts'; /** * The card shown for a body picked in the system view, without leaving it. @@ -24,9 +11,7 @@ interface CardRow { * shows the same numbers over the live view, and keeps the route as the deliberate step for the * full 3D inspection. * - * Measured and derived quantities are kept in separate blocks, each labelled, because the - * difference matters here more than it usually would: no exoplanet has been imaged and several - * of these figures are reasoned rather than observed. Presentational only. + * The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only. */ @Component({ selector: 'app-system-object-card', @@ -43,10 +28,8 @@ interface CardRow {
-

- {{ body().name }} -

-

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

+

{{ body().name }}

+

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

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

Measured

- @for (row of measured(); track row.label) { + @for (row of readouts().measured; track row.label) {
{{ row.label }}
{{ row.value }}
} @@ -72,15 +55,13 @@ interface CardRow {

Derived

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

- {{ provenance() }} -

+

{{ readouts().provenance }}

- `, + ` }) export class SystemObjectCardComponent { readonly body = input.required(); @@ -103,63 +84,5 @@ export class SystemObjectCardComponent { /** Request for the full `/body/:id` route. */ readonly openRequested = output(); - readonly kindLabel = computed(() => KIND_LABELS[this.body().kind]); - - /** Only what a catalogue actually published for this body. */ - readonly measured = computed(() => { - const body = this.body(); - const rows: CardRow[] = []; - if (body.radiusKm !== undefined) { - rows.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) }); - } - if (body.massEarth !== undefined) { - rows.push({ label: 'Mass', value: formatMassEarth(body.massEarth) }); - } - if (body.orbit.semiMajorAxisAu !== undefined) { - rows.push({ label: 'Semi-major axis', value: formatAu(body.orbit.semiMajorAxisAu) }); - } - if (body.orbit.eccentricity !== undefined) { - rows.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) }); - } - if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'measured') { - rows.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) }); - } - if (body.discoveryYear !== undefined) { - rows.push({ label: 'Discovered', value: `${body.discoveryYear}` }); - } - return rows; - }); - - /** Everything computed from the measurements above rather than observed directly. */ - readonly derived = computed(() => { - const body = this.body(); - const rows: CardRow[] = [{ label: 'Class', value: PLANET_CLASS_LABELS[body.appearance.planetClass] }]; - if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'derived') { - rows.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) }); - } - if (body.appearance.equilibriumTemperatureK !== null) { - rows.push({ - label: 'Equilibrium temp.', - value: formatTemperature(body.appearance.equilibriumTemperatureK), - }); - } - if (body.appearance.bulkDensityGramsPerCm3 !== null) { - rows.push({ - label: 'Bulk density', - value: formatDensity(body.appearance.bulkDensityGramsPerCm3), - }); - } - return rows; - }); - - /** The same distinction the detail panel draws, stated briefly enough for a card. */ - readonly provenance = computed(() => { - const body = this.body(); - if (body.hasPhotography) { - return 'Surface: NASA/ESA/USGS photography.'; - } - return body.appearance.equilibriumTemperatureK === null - ? 'Surface illustrated from measured size and mass. No host star in the catalogue, so no temperature could be derived.' - : 'Surface illustrated from the measurements above. Not an observation — no image of this world exists.'; - }); + readonly readouts = computed(() => bodyReadouts(this.body())); } diff --git a/src/app/shared/format/quantity.spec.ts b/src/app/shared/format/quantity.spec.ts index 2199da3..3195cdb 100644 --- a/src/app/shared/format/quantity.spec.ts +++ b/src/app/shared/format/quantity.spec.ts @@ -43,13 +43,15 @@ describe('formatPeriod', () => { }); describe('formatRadiusKm', () => { - it('groups thousands for large bodies', () => { + 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 ones', () => { - expect(formatRadiusKm(6371)).toBe('6371 km'); + it('keeps a decimal only for small bodies', () => { expect(formatRadiusKm(11.3)).toBe('11.3 km'); + expect(formatRadiusKm(1737)).toBe('1,737 km'); }); }); diff --git a/src/app/shared/format/quantity.ts b/src/app/shared/format/quantity.ts index 1f69ab6..5914927 100644 --- a/src/app/shared/format/quantity.ts +++ b/src/app/shared/format/quantity.ts @@ -38,7 +38,10 @@ export function formatPeriod(days: number): string { /** Radius in kilometres, or Earth radii once the number stops being legible in km. */ export function formatRadiusKm(radiusKm: number): string { - return radiusKm >= 10_000 ? `${Math.round(radiusKm).toLocaleString('en-GB')} km` : `${radiusKm.toFixed(radiusKm < 100 ? 1 : 0)} km`; + // 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. */ @@ -68,13 +71,6 @@ export function formatLuminosity(solar: number): string { return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`; } -const SUPERSCRIPTS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']; - function superscript(value: number): string { - const digits = Math.abs(value) - .toString() - .split('') - .map((digit) => SUPERSCRIPTS[Number(digit)]) - .join(''); - return value < 0 ? `⁻${digits}` : digits; + 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; +} From b14ce78c5f0ae61171861166c4e7cc55cf13f985 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:57:40 +0000 Subject: [PATCH 3/5] Say what formatRadiusKm actually returns Its doc comment promised a fallback to Earth radii that the function has never had. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- src/app/shared/format/quantity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/format/quantity.ts b/src/app/shared/format/quantity.ts index 5914927..3ba8403 100644 --- a/src/app/shared/format/quantity.ts +++ b/src/app/shared/format/quantity.ts @@ -36,7 +36,7 @@ export function formatPeriod(days: number): string { return `${(days / 365.25).toFixed(days / 365.25 < 100 ? 1 : 0)} yr`; } -/** Radius in kilometres, or Earth radii once the number stops being legible in km. */ +/** 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. From ddf805e61f87ca71d728b1ca8b672a5bd74785c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:31:53 +0000 Subject: [PATCH 4/5] Have Junie review each pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI says whether the code works. Nothing says whether it reads well, and the one review this repository has had so far arrived by hand. Kept as a separate workflow rather than a third job in ci.yml so a review can never turn the build red — the two answer different questions and should be able to disagree. It skips drafts, and skips pull requests from forks: GitHub withholds secrets from `pull_request` runs on a forked head, so the job would fail on a missing JUNIE_API_KEY rather than say anything about the code. Each push supersedes the previous review rather than stacking another comment beside it. Requires a JUNIE_API_KEY repository secret, generated at junie.jetbrains.com/cli. Without it the workflow is inert. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- .github/workflows/junie-review.yml | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/junie-review.yml diff --git a/.github/workflows/junie-review.yml b/.github/workflows/junie-review.yml new file mode 100644 index 0000000..7a659cb --- /dev/null +++ b/.github/workflows/junie-review.yml @@ -0,0 +1,49 @@ +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 + steps: + - uses: actions/checkout@v5 + 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 + 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" From 4d5e3a99145bf057ee4cbe5a97b18fdcf9652387 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:33:49 +0000 Subject: [PATCH 5/5] Let the Junie review skip rather than fail without a key The workflow as written failed on ddf805e: with no JUNIE_API_KEY secret the input expands to empty and the action exits on "Missing required input", marking the pull request failed for a reason that has nothing to do with its code. I called it inert without the key. It was not inert; it was red, and it would have been red on every pull request until someone added the secret. So gate the steps on the key's presence and write the reason into the run summary instead. `secrets` is not a context a step's `if` can read and neither `secrets` nor `env` is available to a job-level `if`, so the presence is resolved once into a job-level env var, which steps can read. The action step also gets continue-on-error: an outage or a rate limit at JetBrains' end is worth seeing in the log, but this workflow is meant to be an opinion beside CI rather than a gate in front of it, and a failure to obtain that opinion should not hold a pull request whose tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- .github/workflows/junie-review.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/junie-review.yml b/.github/workflows/junie-review.yml index 7a659cb..2694e50 100644 --- a/.github/workflows/junie-review.yml +++ b/.github/workflows/junie-review.yml @@ -31,14 +31,34 @@ jobs: 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