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
@@ -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();
});
});