Constellation Map — Real Star Coordinates as a Navigation Chart
Projecting Hipparcos catalog star coordinates onto a 2D Canvas and building an interactive navigation chart of all 88 constellations
The Problem with Approximate Star Positions
My first approach to building a star map was to place dots at approximate positions and connect them with lines. It looked plausible — but when compared against the actual night sky, the positions were off.
To do it properly required real catalog data. The Hipparcos satellite mission produced precise coordinates and brightness measurements for over 117,000 stars. I extracted all stars brighter than magnitude 6.5 — those visible to the naked eye under dark skies — and used that as the data source.
The Coordinate System — Right Ascension and Declination
Celestial coordinates use Right Ascension (RA) and Declination (Dec), analogous to longitude and latitude on Earth. RA is measured in hours (0h–24h) rather than degrees.
interface Star {
ra: number; // Right Ascension (converted to degrees: hours × 15)
dec: number; // Declination (-90 to +90 degrees)
mag: number; // Apparent magnitude (lower = brighter)
hip: number; // Hipparcos catalog ID
}
The catalog provides RA in hour angles, so a × 15 conversion maps them to degrees before any trigonometry.
Spherical Projection — Mapping the Sky to a Flat Screen
To draw the celestial sphere on a 2D Canvas, a projection is required. Orthographic projection maps a hemisphere centered on a chosen viewpoint into a circular region.
function project(
ra: number,
dec: number,
centerRa: number,
centerDec: number,
scale: number,
W: number,
H: number,
): { x: number; y: number; visible: boolean } {
const cosDist =
Math.sin(deg2rad(dec)) * Math.sin(deg2rad(centerDec)) +
Math.cos(deg2rad(dec)) * Math.cos(deg2rad(centerDec)) * Math.cos(deg2rad(ra - centerRa));
if (cosDist < 0) return { x: 0, y: 0, visible: false }; // behind viewpoint
const dRa = deg2rad(ra - centerRa);
const px = Math.cos(deg2rad(dec)) * Math.sin(dRa);
const py =
Math.sin(deg2rad(dec)) * Math.cos(deg2rad(centerDec)) -
Math.cos(deg2rad(dec)) * Math.sin(deg2rad(centerDec)) * Math.cos(dRa);
return { x: W / 2 + px * scale, y: H / 2 - py * scale, visible: true };
}
Stars more than 90 degrees from the view center are marked visible: false and excluded.
Finding Constellation Centers — The Circular Mean Problem
Computing the screen center of a constellation from its member stars cannot use simple arithmetic averaging. Stars near RA 0° and RA 360° would average to ~180° — completely wrong.
The solution is circular mean using trigonometry:
function getConstellationCenter(stars: Star[]): { ra: number; dec: number } {
const ras = stars.map(s => s.ra);
const sinSum = ras.reduce((s, r) => s + Math.sin((r * Math.PI) / 180), 0);
const cosSum = ras.reduce((s, r) => s + Math.cos((r * Math.PI) / 180), 0);
const centerRa = ((Math.atan2(sinSum, cosSum) * 180) / Math.PI + 360) % 360;
const centerDec = stars.reduce((s, st) => s + st.dec, 0) / stars.length;
return { ra: centerRa, dec: centerDec };
}
RA uses vector averaging to handle wrap-around. Declination uses a simple mean since it doesn't wrap.
Mini Constellation Previews
Before selecting a constellation, a small thumbnail renders its stars and connecting lines in a 64×64 offscreen canvas, normalized to the constellation's bounding box.
function drawMiniConstellation(
ctx: CanvasRenderingContext2D,
stars: Star[],
lines: [number, number][], // pairs of Hipparcos IDs
size: number,
): void {
// normalize to constellation bounding box → fit to size×size
// draw lines first, then star dots
}
This gives users a visual cue about the constellation's shape before committing to navigation.
Drag and Pinch Navigation
Mouse drag and touch pinch let users rotate the view across the sky. Screen pixel deltas are converted to angular deltas:
const deltaRa = -(dx / scale) * (180 / Math.PI);
const deltaDec = (dy / scale) * (180 / Math.PI);
Declination is clamped to ±90°. RA wraps around 0°–360°.
Canvas Performance — Color String Caching
Rendering thousands of stars every frame generates a surprising amount of garbage. Each star's fillStyle is an rgba(...) string built from its magnitude — and with 1,900+ visible stars, that means 1,900+ string allocations per frame, roughly 60 times per second.
A Map-based cache eliminates this. Magnitude values are quantized to two decimal places, and the resulting rgba string is stored and reused on subsequent frames.
const colorCache = new Map<number, string>();
function getStarColor(mag: number): string {
const key = Math.round(mag * 100);
let c = colorCache.get(key);
if (!c) {
const alpha = magToAlpha(mag);
c = `rgba(255, 255, 255, ${alpha})`;
colorCache.set(key, c);
}
return c;
}
This reduces per-frame string allocations by roughly 85%. The cache grows to at most a few hundred entries (the range of visible magnitudes is narrow), so memory overhead is negligible. On lower-end devices, this change alone smoothed out frame drops during pan gestures.
Closing Thoughts
After finishing the code, I centered the view on Orion and confirmed that the three belt stars — Alnitak, Alnilam, and Mintaka — aligned in a nearly perfect line, exactly as they appear in the sky. People have been using those same stars for navigation for thousands of years. That continuity, rendered from catalog coordinates on a browser canvas, felt worth building.