Assemble a body's readouts once, not once per panel
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -125,14 +125,8 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.viewModel.set(viewModel);
|
this.viewModel.set(viewModel);
|
||||||
|
|
||||||
const body = this.bodies.find((candidate) => candidate.id === id);
|
if (viewModel.hostStarId !== undefined) {
|
||||||
if (body) {
|
this.navigationStore.selectStar(viewModel.hostStarId);
|
||||||
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.notFound.set(false);
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface BodyDetailViewModel {
|
|||||||
name: string;
|
name: string;
|
||||||
kind: BodyDetailKind;
|
kind: BodyDetailKind;
|
||||||
hostStarName: string;
|
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;
|
radiusKm?: number;
|
||||||
massEarth?: number;
|
massEarth?: number;
|
||||||
discoveryYear?: number;
|
discoveryYear?: number;
|
||||||
|
|||||||
@@ -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<Record<BodyDetailViewModel['kind'], string>> = {
|
||||||
|
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.';
|
||||||
|
}
|
||||||
@@ -118,8 +118,7 @@ describe('buildBodyViewModel', () => {
|
|||||||
expect(buildBodyViewModel('nowhere', catalogues)).toBeUndefined();
|
expect(buildBodyViewModel('nowhere', catalogues)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads the same body identically however it is reached', () => {
|
it('carries the host star id, so callers need not rescan the catalogues for it', () => {
|
||||||
// The whole point of the shared builder: the card and the detail route must not drift.
|
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
|
||||||
expect(buildBodyViewModel('earth', catalogues)).toEqual(buildBodyViewModel('earth', catalogues));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,17 +41,19 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
|||||||
const body = catalogues.bodies.find((candidate) => candidate.id === id);
|
const body = catalogues.bodies.find((candidate) => candidate.id === id);
|
||||||
if (body) {
|
if (body) {
|
||||||
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
|
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
|
||||||
|
const periodDays = heliocentricPeriodDays(body);
|
||||||
return {
|
return {
|
||||||
id: body.id,
|
id: body.id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
kind: body.kind,
|
kind: body.kind,
|
||||||
hostStarName: hostStar?.name ?? 'Unknown star',
|
hostStarName: hostStar?.name ?? 'Unknown star',
|
||||||
|
hostStarId: body.systemStarId,
|
||||||
radiusKm: body.radiusKm,
|
radiusKm: body.radiusKm,
|
||||||
orbit: body.orbit,
|
orbit: body.orbit,
|
||||||
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
|
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
|
||||||
hasPhotography: bodyTexturePath(body.id) !== undefined,
|
hasPhotography: bodyTexturePath(body.id) !== undefined,
|
||||||
orbitalPeriodDays: heliocentricPeriodDays(body),
|
orbitalPeriodDays: periodDays,
|
||||||
orbitalPeriodSource: heliocentricPeriodDays(body) === undefined ? undefined : 'derived',
|
orbitalPeriodSource: periodDays === undefined ? undefined : 'derived',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
|||||||
name: exoplanet.name,
|
name: exoplanet.name,
|
||||||
kind: 'exoplanet',
|
kind: 'exoplanet',
|
||||||
hostStarName: exoplanet.hostStarName,
|
hostStarName: exoplanet.hostStarName,
|
||||||
|
hostStarId: exoplanet.hostStarId ?? undefined,
|
||||||
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
|
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
|
||||||
massEarth: exoplanet.massEarth,
|
massEarth: exoplanet.massEarth,
|
||||||
discoveryYear: exoplanet.discoveryYear,
|
discoveryYear: exoplanet.discoveryYear,
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import { Component, computed, input } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
|
import { bodyReadouts } from './body-readouts';
|
||||||
import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity';
|
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
|
|
||||||
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
|
||||||
planet: 'Planet',
|
|
||||||
moon: 'Moon',
|
|
||||||
dwarf: 'Dwarf planet',
|
|
||||||
exoplanet: 'Exoplanet'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
|
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
|
||||||
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
||||||
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
|
* `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({
|
@Component({
|
||||||
selector: 'app-info-panel',
|
selector: 'app-info-panel',
|
||||||
@@ -33,104 +28,37 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
|
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
|
||||||
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
|
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
|
|
||||||
|
@if (readouts().measured.length) {
|
||||||
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
|
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
||||||
@if (body().radiusKm) {
|
@for (row of readouts().measured; track row.label) {
|
||||||
<dt class="text-muted">Radius</dt>
|
<dt class="text-muted">{{ row.label }}</dt>
|
||||||
<dd class="text-right text-text tabular-nums">{{ radius() }}</dd>
|
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
||||||
}
|
|
||||||
@if (body().massEarth) {
|
|
||||||
<dt class="text-muted">Mass</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ mass() }}</dd>
|
|
||||||
}
|
|
||||||
@if (body().orbit.semiMajorAxisAu) {
|
|
||||||
<dt class="text-muted">Semi-major axis</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ semiMajorAxis() }}</dd>
|
|
||||||
}
|
|
||||||
@if (body().orbit.eccentricity !== undefined) {
|
|
||||||
<dt class="text-muted">Eccentricity</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ body().orbit.eccentricity!.toFixed(4) }}</dd>
|
|
||||||
}
|
|
||||||
@if (body().orbit.inclinationDeg !== undefined) {
|
|
||||||
<dt class="text-muted">Inclination</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ body().orbit.inclinationDeg!.toFixed(2) }}°</dd>
|
|
||||||
}
|
|
||||||
@if (measuredPeriod()) {
|
|
||||||
<dt class="text-muted">Period</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ measuredPeriod() }}</dd>
|
|
||||||
}
|
|
||||||
@if (body().discoveryYear) {
|
|
||||||
<dt class="text-muted">Discovered</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ body().discoveryYear }}</dd>
|
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
||||||
<dt class="text-muted">Class</dt>
|
@for (row of readouts().derived; track row.label) {
|
||||||
<dd class="text-right text-text">{{ classLabel() }}</dd>
|
<dt class="text-muted">{{ row.label }}</dt>
|
||||||
@if (derivedPeriod()) {
|
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
||||||
<dt class="text-muted">Period</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ derivedPeriod() }}</dd>
|
|
||||||
}
|
|
||||||
@if (body().appearance.equilibriumTemperatureK !== null) {
|
|
||||||
<dt class="text-muted">Equilibrium temp.</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ temperature() }}</dd>
|
|
||||||
}
|
|
||||||
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
|
|
||||||
<dt class="text-muted">Bulk density</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ density() }}</dd>
|
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
|
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class InfoPanelComponent {
|
export class InfoPanelComponent {
|
||||||
readonly body = input.required<BodyDetailViewModel>();
|
readonly body = input.required<BodyDetailViewModel>();
|
||||||
|
|
||||||
readonly radius = computed(() => formatRadiusKm(this.body().radiusKm!));
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
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) {}
|
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 {
|
goBack(): void {
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,9 +128,6 @@ const EXIT_DURATION_SECONDS = 0.9;
|
|||||||
const RETURN_DURATION_SECONDS = 1.1;
|
const RETURN_DURATION_SECONDS = 1.1;
|
||||||
const GALACTIC_FLIGHT_SECONDS = 2.4;
|
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
|
* 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
|
* 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.
|
* over. Undefined outside the system view, and cleared when the view leaves one.
|
||||||
*/
|
*/
|
||||||
readonly objectCard = signal<BodyDetailViewModel | undefined>(undefined);
|
readonly objectCard = signal<BodyDetailViewModel | undefined>(undefined);
|
||||||
|
private enterableSystems = 0;
|
||||||
private pinnedBodyId: string | null = null;
|
private pinnedBodyId: string | null = null;
|
||||||
private hoveredBodyId: 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.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
this.bodies = bodies;
|
this.bodies = bodies;
|
||||||
this.exoplanets = exoplanets;
|
this.exoplanets = exoplanets;
|
||||||
|
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
|
||||||
|
this.enterableSystems = new Set<number>([...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
|
// 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.
|
// labels are recomputed whenever the camera moves.
|
||||||
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
|
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: '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.
|
// 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.');
|
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]);
|
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<number>();
|
|
||||||
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). */
|
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
|
||||||
private reconcileSelection(selectedStarId: number | null): void {
|
private reconcileSelection(selectedStarId: number | null): void {
|
||||||
if (this.busy || selectedStarId === this.currentStarId) {
|
if (this.busy || selectedStarId === this.currentStarId) {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('SystemObjectCardComponent', () => {
|
|||||||
appearance: appearance({ equilibriumTemperatureK: null }),
|
appearance: appearance({ equilibriumTemperatureK: null }),
|
||||||
});
|
});
|
||||||
expect(block(host, 'Derived')).not.toContain('Equilibrium temp.');
|
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', () => {
|
it('emits rather than navigating, so the scene decides what selection means', () => {
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||||
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
|
import { bodyReadouts } from '../body-detail/body-readouts';
|
||||||
import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity';
|
|
||||||
|
|
||||||
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
|
||||||
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.
|
* 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
|
* shows the same numbers over the live view, and keeps the route as the deliberate step for the
|
||||||
* full 3D inspection.
|
* full 3D inspection.
|
||||||
*
|
*
|
||||||
* Measured and derived quantities are kept in separate blocks, each labelled, because the
|
* The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only.
|
||||||
* 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({
|
@Component({
|
||||||
selector: 'app-system-object-card',
|
selector: 'app-system-object-card',
|
||||||
@@ -43,10 +28,8 @@ interface CardRow {
|
|||||||
<div data-testid="object-card" class="hud-panel px-4 py-3 font-body">
|
<div data-testid="object-card" class="hud-panel px-4 py-3 font-body">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-display text-lg tracking-[0.04em] text-text">
|
<p class="truncate font-display text-lg tracking-[0.04em] text-text">{{ body().name }}</p>
|
||||||
{{ body().name }}
|
<p class="mt-0.5 font-display text-[10px] tracking-[0.22em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 font-display text-[10px] tracking-[0.22em] text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -60,10 +43,10 @@ interface CardRow {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (measured().length) {
|
@if (readouts().measured.length) {
|
||||||
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
|
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
|
||||||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
||||||
@for (row of measured(); track row.label) {
|
@for (row of readouts().measured; track row.label) {
|
||||||
<dt class="text-muted">{{ row.label }}</dt>
|
<dt class="text-muted">{{ row.label }}</dt>
|
||||||
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
||||||
}
|
}
|
||||||
@@ -72,15 +55,13 @@ interface CardRow {
|
|||||||
|
|
||||||
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
||||||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
||||||
@for (row of derived(); track row.label) {
|
@for (row of readouts().derived; track row.label) {
|
||||||
<dt class="text-muted">{{ row.label }}</dt>
|
<dt class="text-muted">{{ row.label }}</dt>
|
||||||
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">
|
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
||||||
{{ provenance() }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -94,7 +75,7 @@ interface CardRow {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`
|
||||||
})
|
})
|
||||||
export class SystemObjectCardComponent {
|
export class SystemObjectCardComponent {
|
||||||
readonly body = input.required<BodyDetailViewModel>();
|
readonly body = input.required<BodyDetailViewModel>();
|
||||||
@@ -103,63 +84,5 @@ export class SystemObjectCardComponent {
|
|||||||
/** Request for the full `/body/:id` route. */
|
/** Request for the full `/body/:id` route. */
|
||||||
readonly openRequested = output<void>();
|
readonly openRequested = output<void>();
|
||||||
|
|
||||||
readonly kindLabel = computed(() => KIND_LABELS[this.body().kind]);
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
|
|
||||||
/** Only what a catalogue actually published for this body. */
|
|
||||||
readonly measured = computed<readonly CardRow[]>(() => {
|
|
||||||
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<readonly CardRow[]>(() => {
|
|
||||||
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.';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,15 @@ describe('formatPeriod', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('formatRadiusKm', () => {
|
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(69_911)).toBe('69,911 km');
|
||||||
|
expect(formatRadiusKm(6371)).toBe('6,371 km');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a decimal only for small ones', () => {
|
it('keeps a decimal only for small bodies', () => {
|
||||||
expect(formatRadiusKm(6371)).toBe('6371 km');
|
|
||||||
expect(formatRadiusKm(11.3)).toBe('11.3 km');
|
expect(formatRadiusKm(11.3)).toBe('11.3 km');
|
||||||
|
expect(formatRadiusKm(1737)).toBe('1,737 km');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export function formatPeriod(days: number): string {
|
|||||||
|
|
||||||
/** Radius in kilometres, or Earth radii once the number stops being legible in km. */
|
/** Radius in kilometres, or Earth radii once the number stops being legible in km. */
|
||||||
export function formatRadiusKm(radiusKm: number): string {
|
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. */
|
/** Mass in Earth masses. */
|
||||||
@@ -68,13 +71,6 @@ export function formatLuminosity(solar: number): string {
|
|||||||
return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPERSCRIPTS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
|
||||||
|
|
||||||
function superscript(value: number): string {
|
function superscript(value: number): string {
|
||||||
const digits = Math.abs(value)
|
return `${value}`.replace('-', '⁻').replace(/\d/g, (digit) => '⁰¹²³⁴⁵⁶⁷⁸⁹'[Number(digit)]);
|
||||||
.toString()
|
|
||||||
.split('')
|
|
||||||
.map((digit) => SUPERSCRIPTS[Number(digit)])
|
|
||||||
.join('');
|
|
||||||
return value < 0 ? `⁻${digits}` : digits;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user