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; +}