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:
Claude
2026-08-04 10:56:30 +00:00
parent 06cf7d2a15
commit 4aca223027
10 changed files with 369 additions and 20 deletions
+85
View File
@@ -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();
});
});