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
@@ -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;