Regenerate the star catalogue, fixing 2331 names and 875 colours
Two ETL bugs, both fixed at the source and then re-run against HYG. Star ids,
ordering and positions are all unchanged, so stars.bin is byte-identical and
every exoplanet cross-reference still resolves.
Names. HYG's `gl` column already carries its own catalogue prefix ("Gl 581",
"GJ 3512"), unlike the bare numbers in `hd` and `hip`, so prefixing it again
produced 2331 of 8750 stars named "Gl GJ 1076". That corrupted three surfaces at
once: search, the on-screen labels, and exoplanet host-star name matching, which
compares normalised names and could never match "glgj1076" to "gj1076".
Colours. `Number(row['ci']) || 0` cannot tell a blank cell from a real zero, and
0 is a real B-V colour index meaning a hot blue-white A-type star. All 875
affected stars turned out to be blanks — the catalogue contains no genuine zero
inside the distance cutoff — so several hundred red dwarfs were rendering
blue-white. colorIndex is now `number | null` rather than defaulted, because any
numeric default is indistinguishable from a measurement.
Consumers resolve the gap from the spectral type instead. That needs real
parsing: HYG's `spect` column runs to 134 distinct spellings among the affected
stars alone, including a bare lowercase "m" for 354 of them, plus "k-m" ranges,
"dM4" luminosity prefixes and "K:" uncertainty flags. 622 of the 875 recover a
class this way — 497 of them M-class — and the remaining 253, which carry no
classification at all, fall back to neutral white.
The parse is anchored at the start of the string rather than scanning it. A scan
is the obvious implementation and is quietly wrong: the ETL writes the literal
"Unknown" for unclassified stars, that contains a K, and every one of those 253
would have been classified as an orange K-type. A test covers it.
Also lifts parseOptionalNumber out of fetchExoplanets into lib/csv, where both
fetchers now use it, and gives magnitude a faint default instead of 0 — no
current star is affected, but 0 would mean "as bright as Vega" and render an
unphotometered star as one of the largest points on the map.
Tests: 145 passing, up from 116, including the first coverage of
StarFieldRenderer. Build, both typechecks and the Playwright suite are green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -396,7 +396,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.systemGroup.add(this.systemRenderer.object);
|
||||
|
||||
const starMarkerMaterial = this.starMarkerMaterial.clone();
|
||||
const starColor = colorIndexToRgb(star.colorIndex);
|
||||
const starColor = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
if (star.id === SOL_STAR_ID) {
|
||||
// The Sun is the only star we have (and could ever have) a real photograph of; every
|
||||
// other point in the galaxy view is far too distant to be resolved as a disk.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
|
||||
function star(overrides: Partial<StarRecord> = {}): StarRecord {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Test Star',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
magnitude: 5,
|
||||
spectralType: 'G2V',
|
||||
colorIndex: 0.65,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('colorIndexToRgb', () => {
|
||||
it('tints a hot, low-index star blue-white', () => {
|
||||
const color = colorIndexToRgb(-0.3);
|
||||
expect(color.b).toBeGreaterThan(color.r);
|
||||
});
|
||||
|
||||
it('tints a cool, high-index star orange-red', () => {
|
||||
const color = colorIndexToRgb(1.8);
|
||||
expect(color.r).toBeGreaterThan(color.b);
|
||||
});
|
||||
|
||||
it('moves monotonically from blue toward red as the index rises', () => {
|
||||
const blueness = [-0.3, 0.2, 0.65, 1.2, 1.9].map((index) => {
|
||||
const color = colorIndexToRgb(index);
|
||||
return color.b - color.r;
|
||||
});
|
||||
expect([...blueness].sort((a, b) => b - a)).toEqual(blueness);
|
||||
});
|
||||
|
||||
describe('when the catalog has no photometry', () => {
|
||||
// ~10% of nearby HYG stars have a blank colour-index cell. Reading that as 0 (which is a
|
||||
// real index, meaning a hot A-type star) painted several hundred red dwarfs blue-white.
|
||||
it('falls back to the spectral type rather than to zero', () => {
|
||||
const fromNull = colorIndexToRgb(null, 'M4');
|
||||
const asIfZero = colorIndexToRgb(0);
|
||||
|
||||
expect(fromNull.r).toBeGreaterThan(fromNull.b);
|
||||
expect(asIfZero.b).toBeGreaterThan(asIfZero.r);
|
||||
});
|
||||
|
||||
it('matches the colour the same spectral type would give explicitly', () => {
|
||||
// K5 sits halfway between the K anchor (0.81) and the M anchor (1.40).
|
||||
const derived = colorIndexToRgb(null, 'K5');
|
||||
const explicit = colorIndexToRgb(1.105);
|
||||
|
||||
expect(derived.r).toBeCloseTo(explicit.r, 6);
|
||||
expect(derived.g).toBeCloseTo(explicit.g, 6);
|
||||
expect(derived.b).toBeCloseTo(explicit.b, 6);
|
||||
});
|
||||
|
||||
it('handles the bare lowercase classes HYG ships', () => {
|
||||
const color = colorIndexToRgb(null, 'm');
|
||||
expect(color.r).toBeGreaterThan(color.b);
|
||||
});
|
||||
|
||||
it('falls back to neutral when the star is unclassified too', () => {
|
||||
const color = colorIndexToRgb(null, 'Unknown');
|
||||
expect(color.r).toBeCloseTo(1, 6);
|
||||
expect(color.g).toBeCloseTo(1, 6);
|
||||
expect(color.b).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('is neutral when no spectral type is passed at all', () => {
|
||||
const color = colorIndexToRgb(null);
|
||||
expect(color.r).toBeCloseTo(color.b, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a measured index over the spectral type', () => {
|
||||
// A measured index always wins, even if it disagrees with the classification.
|
||||
const measured = colorIndexToRgb(-0.3, 'M5');
|
||||
expect(measured.b).toBeGreaterThan(measured.r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer', () => {
|
||||
const stars = [star({ id: 10, name: 'A' }), star({ id: 20, name: 'B', colorIndex: null, spectralType: 'M4' })];
|
||||
const positions = new Float32Array([0, 0, 0, 1, 2, 3]);
|
||||
|
||||
it('builds one vertex per star with position, colour and size attributes', () => {
|
||||
const renderer = new StarFieldRenderer(stars, positions);
|
||||
const geometry = renderer.object.geometry;
|
||||
|
||||
expect(geometry.getAttribute('position').count).toBe(2);
|
||||
expect(geometry.getAttribute('starColor').count).toBe(2);
|
||||
expect(geometry.getAttribute('starSize').count).toBe(2);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('maps a vertex index back to its HYG star id', () => {
|
||||
const renderer = new StarFieldRenderer(stars, positions);
|
||||
|
||||
expect(renderer.starIdAt(0)).toBe(10);
|
||||
expect(renderer.starIdAt(1)).toBe(20);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('returns undefined for an out-of-range index', () => {
|
||||
const renderer = new StarFieldRenderer(stars, positions);
|
||||
|
||||
expect(renderer.starIdAt(99)).toBeUndefined();
|
||||
expect(renderer.starIdAt(-1)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('colours an unphotometered star from its spectral type', () => {
|
||||
const renderer = new StarFieldRenderer(stars, positions);
|
||||
const colors = renderer.object.geometry.getAttribute('starColor');
|
||||
|
||||
// Star B is an M4 with no measured index — it must come out red, not blue-white.
|
||||
expect(colors.getX(1)).toBeGreaterThan(colors.getZ(1));
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('renders brighter stars as larger points', () => {
|
||||
const renderer = new StarFieldRenderer([star({ id: 1, magnitude: -1 }), star({ id: 2, magnitude: 12 })], new Float32Array(6));
|
||||
const sizes = renderer.object.geometry.getAttribute('starSize');
|
||||
|
||||
expect(sizes.getX(0)).toBeGreaterThan(sizes.getX(1));
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('handles an empty star field', () => {
|
||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||
|
||||
expect(renderer.object.geometry.getAttribute('position').count).toBe(0);
|
||||
expect(renderer.starIdAt(0)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { attribute } from 'three/tsl';
|
||||
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
const MIN_POINT_SIZE = 1.5;
|
||||
@@ -13,10 +14,20 @@ const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
/**
|
||||
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
|
||||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
||||
*
|
||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
||||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
||||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
||||
* and would paint several hundred red dwarfs blue-white.
|
||||
*/
|
||||
export function colorIndexToRgb(colorIndex: number): THREE.Color {
|
||||
const t = THREE.MathUtils.clamp((colorIndex + 0.4) / 2.4, 0, 1);
|
||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
||||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
||||
const color = new THREE.Color();
|
||||
if (resolved === null) {
|
||||
return color.copy(NEUTRAL_STAR_COLOR);
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1);
|
||||
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
|
||||
}
|
||||
|
||||
@@ -49,7 +60,7 @@ export class StarFieldRenderer {
|
||||
const sizes = new Float32Array(stars.length);
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
const color = colorIndexToRgb(star.colorIndex);
|
||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
colors[index * 3] = color.r;
|
||||
colors[index * 3 + 1] = color.g;
|
||||
colors[index * 3 + 2] = color.b;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSpectralClass, SPECTRAL_CLASSES, spectralTypeToColorIndex } from './spectral';
|
||||
|
||||
describe('parseSpectralClass', () => {
|
||||
it('reads a clean class and subclass', () => {
|
||||
expect(parseSpectralClass('M3.5')).toEqual({ spectralClass: 'M', subclass: 3.5 });
|
||||
expect(parseSpectralClass('G2V')).toEqual({ spectralClass: 'G', subclass: 2 });
|
||||
});
|
||||
|
||||
it('defaults the subclass to 0 when only a class is given', () => {
|
||||
expect(parseSpectralClass('K')).toEqual({ spectralClass: 'K', subclass: 0 });
|
||||
});
|
||||
|
||||
it('accepts the lowercase forms HYG actually ships', () => {
|
||||
// 354 nearby stars are classified as a bare lowercase "m".
|
||||
expect(parseSpectralClass('m')).toEqual({ spectralClass: 'M', subclass: 0 });
|
||||
expect(parseSpectralClass('k')).toEqual({ spectralClass: 'K', subclass: 0 });
|
||||
});
|
||||
|
||||
it('skips a luminosity prefix to find the class', () => {
|
||||
expect(parseSpectralClass('dM4')?.spectralClass).toBe('M');
|
||||
expect(parseSpectralClass('sdM')?.spectralClass).toBe('M');
|
||||
expect(parseSpectralClass('gK5')?.spectralClass).toBe('K');
|
||||
});
|
||||
|
||||
it('takes the warmer end of a range', () => {
|
||||
expect(parseSpectralClass('k-m')?.spectralClass).toBe('K');
|
||||
expect(parseSpectralClass('g-k')?.spectralClass).toBe('G');
|
||||
});
|
||||
|
||||
it('tolerates uncertainty flags and luminosity suffixes', () => {
|
||||
expect(parseSpectralClass('K:')).toEqual({ spectralClass: 'K', subclass: 0 });
|
||||
expect(parseSpectralClass('K5 V')).toEqual({ spectralClass: 'K', subclass: 5 });
|
||||
expect(parseSpectralClass('m+')).toEqual({ spectralClass: 'M', subclass: 0 });
|
||||
});
|
||||
|
||||
it('returns null when there is no recognisable class', () => {
|
||||
for (const input of ['', ' ', '...', undefined, null]) {
|
||||
expect(parseSpectralClass(input)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores an out-of-range subclass rather than trusting it', () => {
|
||||
expect(parseSpectralClass('M42')).toEqual({ spectralClass: 'M', subclass: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('spectralTypeToColorIndex', () => {
|
||||
it('places the Sun near its real B-V of 0.65', () => {
|
||||
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
|
||||
});
|
||||
|
||||
it('makes hot classes blue (negative) and cool classes red (positive)', () => {
|
||||
expect(spectralTypeToColorIndex('O5')).toBeLessThan(0);
|
||||
expect(spectralTypeToColorIndex('B0')).toBeLessThan(0);
|
||||
expect(spectralTypeToColorIndex('M5')).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('increases monotonically from hot to cool across the sequence', () => {
|
||||
const values = SPECTRAL_CLASSES.map((spectralClass) => spectralTypeToColorIndex(spectralClass)!);
|
||||
expect([...values].sort((a, b) => a - b)).toEqual(values);
|
||||
});
|
||||
|
||||
it('interpolates between class anchors by subclass', () => {
|
||||
const g0 = spectralTypeToColorIndex('G0')!;
|
||||
const g5 = spectralTypeToColorIndex('G5')!;
|
||||
const k0 = spectralTypeToColorIndex('K0')!;
|
||||
|
||||
expect(g5).toBeGreaterThan(g0);
|
||||
expect(g5).toBeLessThan(k0);
|
||||
expect(g5).toBeCloseTo((g0 + k0) / 2, 6);
|
||||
});
|
||||
|
||||
it('keeps the coolest subclasses inside a sane range', () => {
|
||||
const m9 = spectralTypeToColorIndex('M9')!;
|
||||
expect(m9).toBeGreaterThan(spectralTypeToColorIndex('M0')!);
|
||||
expect(m9).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('returns null for an unclassified star', () => {
|
||||
expect(spectralTypeToColorIndex('Unknown')).toBeNull();
|
||||
expect(spectralTypeToColorIndex('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Spectral classification helpers.
|
||||
*
|
||||
* HYG leaves the B-V colour index blank for ~10% of nearby stars, but usually still records
|
||||
* *some* spectral classification. Since colour index is only used to tint a star on screen, a
|
||||
* class-derived approximation is far better than showing those stars in a default colour that
|
||||
* happens to mean "hot and blue-white".
|
||||
*/
|
||||
|
||||
/** Harvard spectral classes, hottest to coolest. */
|
||||
export const SPECTRAL_CLASSES = ['O', 'B', 'A', 'F', 'G', 'K', 'M'] as const;
|
||||
|
||||
export type SpectralClass = (typeof SPECTRAL_CLASSES)[number];
|
||||
|
||||
/**
|
||||
* Representative main-sequence B-V colour index at subclass 0 of each class, plus a terminal
|
||||
* anchor past M9 so the coolest subclasses have something to interpolate toward. Standard
|
||||
* textbook values; precise enough for a colour tint, not for photometry.
|
||||
*/
|
||||
const COLOR_INDEX_ANCHORS: Readonly<Record<SpectralClass, number>> = {
|
||||
O: -0.33,
|
||||
B: -0.3,
|
||||
A: 0.0,
|
||||
F: 0.3,
|
||||
G: 0.58,
|
||||
K: 0.81,
|
||||
M: 1.4
|
||||
};
|
||||
|
||||
/** B-V at the cool end of class M, used as the upper interpolation bound. */
|
||||
const BEYOND_M = 2.0;
|
||||
|
||||
/**
|
||||
* Optional lowercase luminosity prefix (`d` dwarf, `sd` subdwarf, `g` giant, `c` supergiant),
|
||||
* stripped only when a class letter follows it immediately. The guard matters for `g-k`, where
|
||||
* the leading `g` is the class G opening a range rather than a giant prefix.
|
||||
*/
|
||||
const LUMINOSITY_PREFIX = /^(?:sd|[dgc])(?=[OBAFGKMobafgkm])/;
|
||||
|
||||
/** Class letter, optional subclass — anchored at the start of what remains. */
|
||||
const SPECTRAL_CLASS_PATTERN = /^([OBAFGKM])\s*(\d+(?:\.\d+)?)?/;
|
||||
|
||||
/**
|
||||
* Pulls the spectral class and (optional) numeric subclass out of a catalog string.
|
||||
*
|
||||
* HYG's `spect` column is inconsistent — `M3.5`, a bare lowercase `m`, `K5 V`, `dM4` with a
|
||||
* luminosity prefix, `K:` flagged uncertain, `k-m` for a range. Rather than trying to parse a
|
||||
* grammar that does not exist, this strips any luminosity prefix and reads the class off the
|
||||
* front. For a range like `k-m` that yields the warmer end, which is the conventional reading.
|
||||
*
|
||||
* The match is anchored rather than a free scan of the string. Scanning looks tempting and is
|
||||
* wrong: the ETL writes the literal `Unknown` for unclassified stars, and that contains a `K`,
|
||||
* so a scan silently classifies every unclassified star as an orange K-type.
|
||||
*/
|
||||
export function parseSpectralClass(
|
||||
spectralType: string | null | undefined
|
||||
): { spectralClass: SpectralClass; subclass: number } | null {
|
||||
const trimmed = (spectralType ?? '').trim().replace(LUMINOSITY_PREFIX, '');
|
||||
const match = SPECTRAL_CLASS_PATTERN.exec(trimmed.toUpperCase());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spectralClass = match[1] as SpectralClass;
|
||||
const parsed = match[2] === undefined ? 0 : Number(match[2]);
|
||||
// Subclasses run 0-9; anything else is a misparse, so fall back to the class midpoint.
|
||||
const subclass = Number.isFinite(parsed) && parsed >= 0 && parsed < 10 ? parsed : 0;
|
||||
|
||||
return { spectralClass, subclass };
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate B-V colour index for a spectral type, interpolating between the class anchors by
|
||||
* subclass. Returns `null` when no class can be recognised, which is the honest answer for the
|
||||
* stars HYG leaves entirely unclassified.
|
||||
*/
|
||||
export function spectralTypeToColorIndex(spectralType: string | null | undefined): number | null {
|
||||
const parsed = parseSpectralClass(spectralType);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { spectralClass, subclass } = parsed;
|
||||
const index = SPECTRAL_CLASSES.indexOf(spectralClass);
|
||||
const from = COLOR_INDEX_ANCHORS[spectralClass];
|
||||
const to = index === SPECTRAL_CLASSES.length - 1 ? BEYOND_M : COLOR_INDEX_ANCHORS[SPECTRAL_CLASSES[index + 1]];
|
||||
|
||||
return from + (to - from) * (subclass / 10);
|
||||
}
|
||||
@@ -12,7 +12,14 @@ export interface StarRecord {
|
||||
z: number;
|
||||
magnitude: number;
|
||||
spectralType: string;
|
||||
colorIndex: number;
|
||||
/**
|
||||
* B-V colour index, or `null` where the catalog has no photometry — about 10% of stars
|
||||
* within the distance cutoff. Deliberately nullable rather than defaulted: `0` is a real,
|
||||
* meaningful colour index (a hot blue-white A-type star), so using it to stand for "unknown"
|
||||
* silently mis-colours those stars. Consumers resolve the gap from `spectralType`; see
|
||||
* `colorIndexToRgb`.
|
||||
*/
|
||||
colorIndex: number | null;
|
||||
}
|
||||
|
||||
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user