The Star Catalog
If you're interested, ask here.
This is the fifth article in a series converting starplot from Python to JavaScript. We load a real star catalog and build the data layer that feeds thousands of stars into our projection and rendering pipeline.
The elder Tuareg looks up at the sky and begins to count. The younger one stops him after twenty — There are too many. Thousands. How do you remember them all?
I do not remember them all, the elder says. I remember the ones that matter. The bright ones for navigation, the patterns for the seasons. But a chart must hold /all of them — even the ones too faint for your eyes. A chart is a catalog./
He draws a grid in the sand. Every star has a place. A position, a brightness. If you write them down in order, you can find any star without searching the whole sky.
Why a Catalog?
So far we have worked with a handful of stars — Sirius, Vega, Polaris — hardcoded in our demo arrays. That is enough to test coordinate systems and projections. But a real star chart renders thousands of stars, each with a precise position, brightness, and sometimes a name.
A star catalog is a structured dataset of star measurements. The most important fields for charting are:
- Right Ascension (RA) — the star's longitude on the celestial sphere (degrees)
- Declination (Dec) — the star's latitude on the celestial sphere (degrees)
- Magnitude — the star's apparent brightness (lower = brighter; Sirius is −1.46, the faintest naked-eye stars are ~6.5)
Hipparchus of Nicaea (~190–120 BCE) compiled the first known star catalog — roughly 850 stars with positions and brightness estimates. His magnitude system (1 for the brightest, 6 for the faintest visible) is still the basis of the modern scale. The catalog was lost but preserved through Ptolemy's Almagest. In the Islamic Golden Age, al-Sufi (903–986) revised and extended it in his Book of Fixed Stars, correcting positions and adding observations from 500 years of accumulated precession. The modern Hipparcos satellite (ESA, 1989–1993), named in his honour, measured 118,218 stars with milliarcsecond precision — the most accurate all-sky survey of its era.
The Data Problem
The Python starplot library uses DuckDB to query star catalogs. That makes sense in Python — loop performance is slow, and SQL gives you filtering and aggregation without writing explicit loops.
In JavaScript, we do not need a database. V8 JIT-compiles tight loops over typed arrays to near-native machine code. Filtering 15,000 stars by magnitude takes less than a millisecond. The approach is simpler, faster, and has zero dependencies.
| Approach | Bundle size | Parse time | Query time (15k stars) |
|---|---|---|---|
| DuckDB-Wasm | ~10 MB | ~100ms | ~1ms |
| JSON array | ~1.5 MB (300KB gz) | ~5-15ms | ~1ms |
| Binary columnar | ~180 KB (70KB gz) | ~0ms | <1ms |
We use binary columnar format: three contiguous Float32Array columns (RA, Dec, magnitude) packed into a single ArrayBuffer. Loading is instantaneous — new Float32Array(buffer) creates a zero-cost view over the data, no parsing, no object allocation.
Star Types
First, the type definitions. A StarCatalog is the binary data: three typed arrays sharing one buffer. A Star is a single record extracted from the catalog for display or interaction.
/** A single star's data */
export interface Star {
/** Right ascension in degrees (0–360) */
ra: number;
/** Declination in degrees (-90 to +90) */
dec: number;
/** Apparent magnitude (lower = brighter) */
mag: number;
/** Index into the catalog arrays */
index: number;
}
/**
* Binary columnar star catalog.
*
* Three Float32Arrays share a single ArrayBuffer.
* Each array has `count` elements — one per star.
* Stars are sorted by magnitude (brightest first).
*
* Layout: [ra × N][dec × N][mag × N] = 12 bytes per star
*/
export interface StarCatalog {
/** Right ascension column (degrees) */
ra: Float32Array;
/** Declination column (degrees) */
dec: Float32Array;
/** Apparent magnitude column */
mag: Float32Array;
/** Number of stars in the catalog */
count: number;
}
/**
* Star name lookup table.
* Maps catalog index → common name.
* Only ~1,600 of the brightest stars have names.
*/
export type StarNames = Map<number, string>;
Building a Catalog from Data
We need two functions: one to decode a binary buffer into a StarCatalog, and one to encode star data into the binary format. The binary layout is simple: three contiguous blocks of Float32 values, each of length N.
import type { StarCatalog } from './05-star-catalog.org?name=star-types';
/** Bytes per star in the binary format: 3 × Float32 = 12 */
export const BYTES_PER_STAR = 12;
/**
* Decode a binary buffer into a StarCatalog.
*
* The buffer must contain exactly N × 12 bytes,
* where N is the number of stars. The layout is:
* [ra_0, ra_1, ..., ra_{N-1}] — N × Float32
* [dec_0, dec_1, ..., dec_{N-1}] — N × Float32
* [mag_0, mag_1, ..., mag_{N-1}] — N × Float32
*
* Returns three typed array VIEWS over the same buffer — no copying.
*/
export function decodeCatalog(buffer: ArrayBuffer): StarCatalog {
const byteLength = buffer.byteLength;
if (byteLength % BYTES_PER_STAR !== 0) {
throw new Error(
`Invalid catalog buffer: ${byteLength} bytes is not divisible by ${BYTES_PER_STAR}`
);
}
const count = byteLength / BYTES_PER_STAR;
const ra = new Float32Array(buffer, 0, count);
const dec = new Float32Array(buffer, 4 * count, count);
const mag = new Float32Array(buffer, 8 * count, count);
return { ra, dec, mag, count };
}
/**
* Encode star arrays into a binary buffer.
*
* Accepts parallel arrays of RA, Dec, and magnitude.
* Returns an ArrayBuffer in the columnar layout.
*/
export function encodeCatalog(
ra: ArrayLike<number>,
dec: ArrayLike<number>,
mag: ArrayLike<number>,
): ArrayBuffer {
const count = ra.length;
if (dec.length !== count || mag.length !== count) {
throw new Error('RA, Dec, and Mag arrays must have the same length');
}
const buffer = new ArrayBuffer(count * BYTES_PER_STAR);
const raView = new Float32Array(buffer, 0, count);
const decView = new Float32Array(buffer, 4 * count, count);
const magView = new Float32Array(buffer, 8 * count, count);
for (let i = 0; i < count; i++) {
raView[i] = ra[i];
decView[i] = dec[i];
magView[i] = mag[i];
}
return buffer;
}
Building from JSON
Not every dataset starts as binary. For development and small datasets, we can build a catalog from a JSON array of star objects and a separate star name map. This is the path we use for the embedded demo catalog in this article.
import type { Star, StarCatalog, StarNames } from './05-star-catalog.org?name=star-types';
import { encodeCatalog, decodeCatalog } from './05-star-catalog.org?name=catalog-codec';
/** Raw star entry as found in a JSON catalog file */
export interface RawStarEntry {
ra: number;
dec: number;
mag: number;
name?: string;
}
/**
* Build a StarCatalog and StarNames from a JSON array.
*
* Stars are sorted by magnitude (brightest first) so that
* magnitude-limited queries can stop early.
*/
export function catalogFromJSON(
entries: RawStarEntry[],
): { catalog: StarCatalog; names: StarNames } {
// Sort by magnitude — brightest first
const sorted = [...entries].sort((a, b) => a.mag - b.mag);
const ras = new Float64Array(sorted.length);
const decs = new Float64Array(sorted.length);
const mags = new Float64Array(sorted.length);
const names: StarNames = new Map();
for (let i = 0; i < sorted.length; i++) {
ras[i] = sorted[i].ra;
decs[i] = sorted[i].dec;
mags[i] = sorted[i].mag;
if (sorted[i].name) {
names.set(i, sorted[i].name!);
}
}
const buffer = encodeCatalog(ras, decs, mags);
const catalog = decodeCatalog(buffer);
return { catalog, names };
}
Filtering by Magnitude
The most common query: "give me all stars brighter than magnitude X." Since our catalog is sorted by magnitude (brightest first), we can use binary search to find the cutoff and return a count rather than filtering the entire array.
import type { Star, StarCatalog, StarNames } from './05-star-catalog.org?name=star-types';
/**
* Find the number of stars brighter than (or equal to) a limiting magnitude.
*
* Uses binary search on the sorted magnitude column.
* Returns the count — all stars from index 0 to count-1
* satisfy mag[i] <= limitMag.
*/
export function countBrighter(catalog: StarCatalog, limitMag: number): number {
const { mag, count } = catalog;
if (count === 0) return 0;
if (mag[0] > limitMag) return 0;
if (mag[count - 1] <= limitMag) return count;
let lo = 0, hi = count - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (mag[mid] <= limitMag) lo = mid;
else hi = mid - 1;
}
return lo + 1;
}
/**
* Iterate over stars brighter than a given magnitude.
*
* Yields Star objects with their catalog index.
* Since the catalog is sorted, we simply iterate from
* index 0 to the cutoff.
*/
export function* starsBrighter(
catalog: StarCatalog,
limitMag: number,
): Generator<Star> {
const n = countBrighter(catalog, limitMag);
for (let i = 0; i < n; i++) {
yield {
ra: catalog.ra[i],
dec: catalog.dec[i],
mag: catalog.mag[i],
index: i,
};
}
}
/**
* Filter stars within a rectangular region of the sky.
*
* Returns stars brighter than limitMag that fall within
* the given RA/Dec bounds. Handles RA wraparound (e.g., 350°–10°).
*/
export function starsInRegion(
catalog: StarCatalog,
limitMag: number,
raMin: number, raMax: number,
decMin: number, decMax: number,
): Star[] {
const n = countBrighter(catalog, limitMag);
const results: Star[] = [];
const wraps = raMin > raMax; // crosses 0°/360° boundary
for (let i = 0; i < n; i++) {
const dec = catalog.dec[i];
if (dec < decMin || dec > decMax) continue;
const ra = catalog.ra[i];
const inRA = wraps
? (ra >= raMin || ra <= raMax)
: (ra >= raMin && ra <= raMax);
if (!inRA) continue;
results.push({
ra, dec,
mag: catalog.mag[i],
index: i,
});
}
return results;
}
Star Names
Only about 1,600 stars have commonly used names. We store these separately as a lightweight map — there is no need to burden the binary catalog with string data. The name lookup is a simple function that checks the map and falls back to nothing.
import type { StarNames } from './05-star-catalog.org?name=star-types';
/**
* Look up a star's name by catalog index.
* Returns the name or undefined if the star has no common name.
*/
export function getStarName(
names: StarNames,
index: number,
): string | undefined {
return names.get(index);
}
/**
* Build a StarNames map from a JSON object.
* The JSON maps string indices to name strings:
* { "0": "Sirius", "1": "Canopus", ... }
*/
export function parseStarNames(
json: Record<string, string>,
): StarNames {
const map: StarNames = new Map();
for (const [key, value] of Object.entries(json)) {
map.set(parseInt(key, 10), value);
}
return map;
}
Magnitude to Visual Size
Stars on a chart are not all the same size. Brighter stars appear as larger dots. The relationship between magnitude and display radius follows a logarithmic scale — each magnitude step represents a factor of ~2.512 in brightness (Pogson's ratio).
/**
* Convert apparent magnitude to a display radius in pixels.
*
* Uses a logarithmic scale based on Pogson's ratio:
* each magnitude step = 2.512× brightness difference.
*
* @param mag - Apparent magnitude
* @param baseRadius - Radius for a magnitude-0 star (default: 4)
* @param minRadius - Minimum radius for the faintest stars (default: 0.5)
* @returns Radius in pixels
*/
export function magToRadius(
mag: number,
baseRadius = 4,
minRadius = 0.5,
): number {
// Brightness ratio: 10^(-0.4 * mag) relative to mag 0
// We use the square root for radius (area ~ brightness)
const brightness = Math.pow(10, -0.4 * mag);
const r = baseRadius * Math.sqrt(brightness);
return Math.max(minRadius, r);
}
/**
* Convert apparent magnitude to an opacity value.
*
* Bright stars are fully opaque; faint stars fade out.
*
* @param mag - Apparent magnitude
* @param brightMag - Magnitude at full opacity (default: 1)
* @param faintMag - Magnitude at minimum opacity (default: 7)
* @param minOpacity - Opacity for the faintest stars (default: 0.3)
*/
export function magToOpacity(
mag: number,
brightMag = 1,
faintMag = 7,
minOpacity = 0.3,
): number {
if (mag <= brightMag) return 1;
if (mag >= faintMag) return minOpacity;
const t = (mag - brightMag) / (faintMag - brightMag);
return 1 - t * (1 - minOpacity);
}
Norman Pogson formalized the magnitude scale in 1856, defining a difference of 5 magnitudes as exactly a 100× brightness ratio. This gives the factor 100^(1/5) ≈ 2.512 per magnitude step — now called Pogson's ratio. The original scale from Hipparchus was qualitative (1 = brightest, 6 = faintest visible). Pogson's logarithmic definition made it quantitative, extending below zero for very bright objects (Sirius: −1.46, Venus: up to −4.6) and above 6 for telescopic ones.
Catalog Statistics
A helper to summarize what a catalog contains — useful for debugging and for display in the UI.
import type { StarCatalog, StarNames } from './05-star-catalog.org?name=star-types';
import { countBrighter } from './05-star-catalog.org?name=catalog-filter';
/** Summary statistics for a star catalog */
export interface CatalogStats {
totalStars: number;
brightestMag: number;
faintestMag: number;
nakedEyeStars: number; // mag <= 6.5
binocularStars: number; // mag <= 9.5
namedStars: number;
}
/**
* Compute summary statistics for a catalog.
*/
export function catalogStats(
catalog: StarCatalog,
names: StarNames,
): CatalogStats {
const { mag, count } = catalog;
return {
totalStars: count,
brightestMag: count > 0 ? mag[0] : NaN,
faintestMag: count > 0 ? mag[count - 1] : NaN,
nakedEyeStars: countBrighter(catalog, 6.5),
binocularStars: countBrighter(catalog, 9.5),
namedStars: names.size,
};
}
A Demo Catalog
For this article's interactive visualizations, we embed a small catalog of 50 bright stars directly in the page. In production, the full Hipparcos catalog would be loaded from a binary file — but the API is identical.
import type { RawStarEntry } from './05-star-catalog.org?name=catalog-from-json';
/**
* 50 bright stars for demos.
* RA in degrees, Dec in degrees, apparent magnitude.
* Data from the Hipparcos catalog (J2000 epoch).
*/
export const DEMO_STARS: RawStarEntry[] = [
{ ra: 101.29, dec: -16.72, mag: -1.46, name: "Sirius" },
{ ra: 95.99, dec: -52.70, mag: -0.74, name: "Canopus" },
{ ra: 213.92, dec: 19.18, mag: -0.05, name: "Arcturus" },
{ ra: 279.23, dec: 38.78, mag: 0.03, name: "Vega" },
{ ra: 78.63, dec: -8.20, mag: 0.13, name: "Rigel" },
{ ra: 114.83, dec: 5.22, mag: 0.34, name: "Procyon" },
{ ra: 88.79, dec: 7.41, mag: 0.42, name: "Betelgeuse" },
{ ra: 24.43, dec: -57.24, mag: 0.46, name: "Achernar" },
{ ra: 219.90, dec: -60.84, mag: 0.61, name: "Hadar" },
{ ra: 297.70, dec: 8.87, mag: 0.76, name: "Altair" },
{ ra: 186.65, dec: -63.10, mag: 0.77, name: "Acrux" },
{ ra: 344.41, dec: -29.62, mag: 1.16, name: "Fomalhaut" },
{ ra: 152.09, dec: 11.97, mag: 1.35, name: "Regulus" },
{ ra: 310.36, dec: 45.28, mag: 1.25, name: "Deneb" },
{ ra: 79.17, dec: 46.00, mag: 0.08, name: "Capella" },
{ ra: 68.98, dec: 16.51, mag: 0.85, name: "Aldebaran" },
{ ra: 201.30, dec: -11.16, mag: 0.97, name: "Spica" },
{ ra: 247.35, dec: -26.43, mag: 1.06, name: "Antares" },
{ ra: 283.82, dec: -26.30, mag: 1.85, name: "Kaus Australis" },
{ ra: 116.33, dec: 28.03, mag: 1.14, name: "Pollux" },
{ ra: 37.95, dec: 89.26, mag: 1.98, name: "Polaris" },
{ ra: 263.40, dec: -37.10, mag: 1.63, name: "Shaula" },
{ ra: 104.66, dec: -28.97, mag: 1.50, name: "Wezen" },
{ ra: 191.93, dec: -59.69, mag: 1.33, name: "Mimosa" },
{ ra: 220.48, dec: -47.39, mag: 0.61, name: "Rigil Kentaurus" },
{ ra: 81.28, dec: 6.35, mag: 1.64, name: "Bellatrix" },
{ ra: 84.05, dec: -1.20, mag: 1.69, name: "Alnilam" },
{ ra: 141.90, dec: -8.66, mag: 1.98, name: "Alphard" },
{ ra: 95.74, dec: -17.96, mag: 1.83, name: "Adhara" },
{ ra: 107.10, dec: -26.39, mag: 1.84, name: "Mirzam" },
{ ra: 187.79, dec: -57.11, mag: 1.25, name: "Gacrux" },
{ ra: 264.33, dec: -43.00, mag: 1.87, name: "Sargas" },
{ ra: 252.17, dec: -69.03, mag: 2.06, name: "Atria" },
{ ra: 165.93, dec: 61.75, mag: 1.77, name: "Dubhe" },
{ ra: 206.89, dec: 49.31, mag: 1.86, name: "Alkaid" },
{ ra: 49.87, dec: -40.30, mag: 2.04, name: "Acamar" },
{ ra: 28.66, dec: 20.81, mag: 2.00, name: "Hamal" },
{ ra: 113.65, dec: 31.89, mag: 1.58, name: "Castor" },
{ ra: 348.97, dec: -21.17, mag: 2.04, name: "Deneb Kaitos" },
{ ra: 222.68, dec: -16.04, mag: 2.06, name: "Zubeneschamali" },
{ ra: 266.43, dec: -34.29, mag: 1.63, name: "Kaus Media" },
{ ra: 276.99, dec: -25.42, mag: 2.05, name: "Nunki" },
{ ra: 305.56, dec: -14.78, mag: 2.07, name: "Dabih" },
{ ra: 326.05, dec: 9.88, mag: 2.39, name: "Enif" },
{ ra: 13.79, dec: 15.18, mag: 2.06, name: "Algenib" },
{ ra: 2.10, dec: 29.09, mag: 2.07, name: "Alpheratz" },
{ ra: 10.13, dec: 56.54, mag: 2.27, name: "Schedar" },
{ ra: 14.18, dec: 60.72, mag: 2.24, name: "Caph" },
{ ra: 193.51, dec: 55.96, mag: 1.77, name: "Mizar" },
{ ra: 263.73, dec: 12.56, mag: 2.08, name: "Rasalhague" },
];
Rendering Stars on Projections
Let us see the catalog in action. The visualization below builds a StarCatalog from our 50 demo stars and renders them on a stereographic projection, with each star's size proportional to its brightness.
import { DEMO_STARS } from './05-star-catalog.org?name=demo-catalog';
import { catalogFromJSON } from './05-star-catalog.org?name=catalog-from-json';
import { starsBrighter, countBrighter } from './05-star-catalog.org?name=catalog-filter';
import { magToRadius, magToOpacity } from './05-star-catalog.org?name=mag-to-size';
import { getStarName } from './05-star-catalog.org?name=star-names';
import { stereographic } from './03-projections.org?name=stereographic';
import { clearCanvas, fillCircle } from './01-coordinates.org?name=draw-helpers';
const { catalog, names } = catalogFromJSON(DEMO_STARS);
const W = 640, H = 640;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.style.cssText = "width:100%;max-width:640px;display:block;cursor:grab";
const ctx = canvas.getContext("2d")!;
const cx = W / 2, cy = H / 2;
const SCALE = 220;
let centerLat = 20, centerLon = 100;
let dragging = false, lastX = 0, lastY = 0;
let limitMag = 2.5;
function draw() {
clearCanvas(ctx, W, H, "#0a0c14");
// Projection boundary circle
ctx.strokeStyle = "#3a3a4a";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, SCALE * 2, 0, Math.PI * 2);
ctx.stroke();
// Grid
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, SCALE * 2, 0, Math.PI * 2);
ctx.clip();
for (let lat = -80; lat <= 80; lat += 20) {
ctx.strokeStyle = lat === 0 ? "#2a2a4a" : "#15182a";
ctx.lineWidth = lat === 0 ? 0.8 : 0.4;
ctx.beginPath();
let started = false;
for (let lon = -180; lon <= 180; lon += 2) {
const p = stereographic(lat, lon, centerLat, centerLon);
if (!p.visible) { started = false; continue; }
const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;
if (sx < -10 || sx > W + 10 || sy < -10 || sy > H + 10) { started = false; continue; }
if (!started) { ctx.moveTo(sx, sy); started = true; }
else ctx.lineTo(sx, sy);
}
ctx.stroke();
}
for (let lon = -180; lon < 180; lon += 20) {
ctx.strokeStyle = lon === 0 ? "#2a2a4a" : "#15182a";
ctx.lineWidth = lon === 0 ? 0.8 : 0.4;
ctx.beginPath();
let started = false;
for (let lat = -90; lat <= 90; lat += 2) {
const p = stereographic(lat, lon, centerLat, centerLon);
if (!p.visible) { started = false; continue; }
const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;
if (sx < -10 || sx > W + 10 || sy < -10 || sy > H + 10) { started = false; continue; }
if (!started) { ctx.moveTo(sx, sy); started = true; }
else ctx.lineTo(sx, sy);
}
ctx.stroke();
}
// Stars from catalog
const starCount = countBrighter(catalog, limitMag);
for (const star of starsBrighter(catalog, limitMag)) {
const p = stereographic(star.dec, star.ra, centerLat, centerLon);
if (!p.visible) continue;
const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;
if (sx < -20 || sx > W + 20 || sy < -20 || sy > H + 20) continue;
const r = magToRadius(star.mag, 5, 0.8);
const opacity = magToOpacity(star.mag);
// Glow
const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, r * 4);
grad.addColorStop(0, `rgba(255,255,220,${opacity * 0.15})`);
grad.addColorStop(1, "rgba(255,255,220,0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(sx, sy, r * 4, 0, Math.PI * 2);
ctx.fill();
// Star dot
ctx.globalAlpha = opacity;
fillCircle(ctx, sx, sy, r, "#ffd");
ctx.globalAlpha = 1;
// Name label for bright stars
const name = getStarName(names, star.index);
if (name && star.mag < 1.5) {
ctx.fillStyle = `rgba(136,119,102,${opacity})`;
ctx.font = "10px monospace";
ctx.textAlign = "left";
ctx.fillText(name, sx + r + 4, sy + 3);
}
}
ctx.restore();
// Info
ctx.fillStyle = "#666";
ctx.font = "11px monospace";
ctx.textAlign = "left";
ctx.fillText(
`Stereographic center: ${centerLat.toFixed(0)}°, ${centerLon.toFixed(0)}°` +
` | mag ≤ ${limitMag.toFixed(1)} → ${starCount} stars`,
14, H - 16,
);
ctx.textAlign = "right";
ctx.fillText("drag to pan · scroll to change mag limit", W - 14, H - 16);
}
canvas.addEventListener("mousedown", (e) => {
dragging = true; lastX = e.clientX; lastY = e.clientY;
canvas.style.cursor = "grabbing";
});
canvas.addEventListener("mousemove", (e) => {
if (!dragging) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
centerLon -= dx * 0.3;
centerLat = Math.max(-89, Math.min(89, centerLat + dy * 0.3));
lastX = e.clientX; lastY = e.clientY;
draw();
});
canvas.addEventListener("mouseup", () => { dragging = false; canvas.style.cursor = "grab"; });
canvas.addEventListener("mouseleave", () => { dragging = false; canvas.style.cursor = "grab"; });
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
limitMag = Math.max(-2, Math.min(6, limitMag + e.deltaY * 0.01));
draw();
}, { passive: false });
canvas.addEventListener("touchstart", (e) => {
e.preventDefault();
const t = e.touches[0];
dragging = true; lastX = t.clientX; lastY = t.clientY;
});
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
if (!dragging) return;
const t = e.touches[0];
const dx = t.clientX - lastX, dy = t.clientY - lastY;
centerLon -= dx * 0.3;
centerLat = Math.max(-89, Math.min(89, centerLat + dy * 0.3));
lastX = t.clientX; lastY = t.clientY;
draw();
});
canvas.addEventListener("touchend", () => { dragging = false; });
draw();
export default canvas;
Four Projections with Real Data
The same catalog data rendered on all four projections simultaneously. Each star is sized and faded according to its magnitude. Drag to pan; scroll to adjust the magnitude limit. This demonstrates that the catalog is projection-agnostic — the data layer is completely decoupled from the rendering strategy.
import { DEMO_STARS } from './05-star-catalog.org?name=demo-catalog';
import { catalogFromJSON } from './05-star-catalog.org?name=catalog-from-json';
import { starsBrighter, countBrighter } from './05-star-catalog.org?name=catalog-filter';
import { magToRadius, magToOpacity } from './05-star-catalog.org?name=mag-to-size';
import { getStarName } from './05-star-catalog.org?name=star-names';
import { stereographic } from './03-projections.org?name=stereographic';
import { orthographic } from './03-projections.org?name=orthographic';
import { mercator } from './04-projections-2.org?name=mercator';
import { mollweide } from './04-projections-2.org?name=mollweide';
import type { ProjectionFn } from './03-projections.org?name=projection-types';
import { clearCanvas, fillCircle } from './01-coordinates.org?name=draw-helpers';
const { catalog, names } = catalogFromJSON(DEMO_STARS);
const W = 880, H = 700;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.style.cssText = "width:100%;max-width:880px;display:block;cursor:grab";
const ctx = canvas.getContext("2d")!;
let centerLat = 20, centerLon = 100;
let dragging = false, lastX = 0, lastY = 0;
let limitMag = 2.5;
interface Panel {
proj: ProjectionFn;
label: string;
ox: number; oy: number;
scale: number;
clip: "circle" | "rect" | "ellipse";
cw: number; ch: number;
useLat: boolean;
}
const panels: Panel[] = [
{ proj: stereographic, label: "Stereographic", ox: W/4, oy: H/4+10, scale: 75, clip: "circle", cw: 160, ch: 160, useLat: true },
{ proj: orthographic, label: "Orthographic", ox: 3*W/4, oy: H/4+10, scale: 160, clip: "circle", cw: 160, ch: 160, useLat: true },
{ proj: mercator, label: "Mercator", ox: W/4, oy: 3*H/4-30, scale: 70, clip: "rect", cw: 200, ch: 140, useLat: false },
{ proj: mollweide, label: "Mollweide", ox: 3*W/4, oy: 3*H/4-30, scale: 70, clip: "ellipse", cw: 200, ch: 100, useLat: false },
];
function draw() {
clearCanvas(ctx, W, H, "#0a0c14");
for (const p of panels) {
const lat0 = p.useLat ? centerLat : 0;
ctx.save();
ctx.beginPath();
if (p.clip === "circle") ctx.arc(p.ox, p.oy, p.cw, 0, Math.PI * 2);
else if (p.clip === "ellipse") ctx.ellipse(p.ox, p.oy, p.cw, p.ch, 0, 0, Math.PI * 2);
else ctx.rect(p.ox - p.cw, p.oy - p.ch, p.cw * 2, p.ch * 2);
ctx.clip();
ctx.fillStyle = "#0a0c18";
ctx.fill();
// Grid
for (let lat = -80; lat <= 80; lat += 20) {
ctx.strokeStyle = lat === 0 ? "#2a2a4a" : "#161828";
ctx.lineWidth = lat === 0 ? 0.8 : 0.4;
ctx.beginPath();
let started = false;
for (let lon = -180; lon <= 180; lon += 3) {
const pt = p.proj(lat, lon, lat0, centerLon);
if (!pt.visible) { started = false; continue; }
const sx = p.ox + pt.x * p.scale, sy = p.oy - pt.y * p.scale;
if (!started) { ctx.moveTo(sx, sy); started = true; }
else ctx.lineTo(sx, sy);
}
ctx.stroke();
}
for (let lon = -180; lon < 180; lon += 20) {
ctx.strokeStyle = lon === 0 ? "#2a2a4a" : "#161828";
ctx.lineWidth = lon === 0 ? 0.8 : 0.4;
ctx.beginPath();
let started = false;
for (let lat = -90; lat <= 90; lat += 3) {
const pt = p.proj(lat, lon, lat0, centerLon);
if (!pt.visible) { started = false; continue; }
const sx = p.ox + pt.x * p.scale, sy = p.oy - pt.y * p.scale;
if (!started) { ctx.moveTo(sx, sy); started = true; }
else ctx.lineTo(sx, sy);
}
ctx.stroke();
}
// Stars
for (const star of starsBrighter(catalog, limitMag)) {
const pt = p.proj(star.dec, star.ra, lat0, centerLon);
if (!pt.visible) continue;
const sx = p.ox + pt.x * p.scale, sy = p.oy - pt.y * p.scale;
const r = magToRadius(star.mag, 3, 0.5);
const opacity = magToOpacity(star.mag);
ctx.globalAlpha = opacity;
fillCircle(ctx, sx, sy, r, "#ffd");
ctx.globalAlpha = 1;
const name = getStarName(names, star.index);
if (name && star.mag < 0.5) {
ctx.fillStyle = `rgba(136,119,102,${opacity})`;
ctx.font = "8px monospace";
ctx.textAlign = "left";
ctx.fillText(name, sx + r + 2, sy + 3);
}
}
ctx.restore();
// Border
ctx.strokeStyle = "#4a4a5a";
ctx.lineWidth = 1.5;
ctx.beginPath();
if (p.clip === "circle") ctx.arc(p.ox, p.oy, p.cw, 0, Math.PI * 2);
else if (p.clip === "ellipse") ctx.ellipse(p.ox, p.oy, p.cw, p.ch, 0, 0, Math.PI * 2);
else ctx.rect(p.ox - p.cw, p.oy - p.ch, p.cw * 2, p.ch * 2);
ctx.stroke();
// Label
ctx.fillStyle = "#5a6a8a";
ctx.font = "bold 11px monospace";
ctx.textAlign = "center";
ctx.fillText(p.label, p.ox, p.oy - p.ch - 8);
}
const starCount = countBrighter(catalog, limitMag);
ctx.fillStyle = "#555";
ctx.font = "10px monospace";
ctx.textAlign = "center";
ctx.fillText(
`${starCount} stars (mag ≤ ${limitMag.toFixed(1)}) · drag to pan · scroll to change limit`,
W / 2, H - 10,
);
}
canvas.addEventListener("mousedown", (e) => {
dragging = true; lastX = e.clientX; lastY = e.clientY;
canvas.style.cursor = "grabbing";
});
canvas.addEventListener("mousemove", (e) => {
if (!dragging) return;
centerLon -= (e.clientX - lastX) * 0.4;
centerLat = Math.max(-89, Math.min(89, centerLat + (e.clientY - lastY) * 0.4));
lastX = e.clientX; lastY = e.clientY;
draw();
});
canvas.addEventListener("mouseup", () => { dragging = false; canvas.style.cursor = "grab"; });
canvas.addEventListener("mouseleave", () => { dragging = false; canvas.style.cursor = "grab"; });
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
limitMag = Math.max(-2, Math.min(6, limitMag + e.deltaY * 0.01));
draw();
}, { passive: false });
canvas.addEventListener("touchstart", (e) => {
e.preventDefault();
const t = e.touches[0];
dragging = true; lastX = t.clientX; lastY = t.clientY;
});
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
if (!dragging) return;
const t = e.touches[0];
centerLon -= (t.clientX - lastX) * 0.4;
centerLat = Math.max(-89, Math.min(89, centerLat + (t.clientY - lastY) * 0.4));
lastX = t.clientX; lastY = t.clientY;
draw();
});
canvas.addEventListener("touchend", () => { dragging = false; });
draw();
export default canvas;
The Binary Advantage
Why go through the trouble of binary format when JSON works fine?
For 50 stars it does not matter. For 15,000 stars (magnitude < 8, the range most charts render), the difference is dramatic:
| JSON | Binary | |
|---|---|---|
| File size | ~1.5 MB | ~180 KB |
| Gzipped | ~300 KB | ~70 KB |
| Parse time | 5-15 ms | ~0 ms |
| Memory | 15k objects on heap | 3 typed array views |
| GC pressure | High (object per star) | Zero |
The binary format has zero parse time because new Float32Array(buffer, offset, length) does not copy data — it creates a view over the existing ArrayBuffer. The browser already has the bytes in memory from the network fetch; the typed array just provides structure.
For the full Hipparcos catalog (118,218 stars), the binary file would be ~1.4 MB raw (~500 KB gzipped). Still fast enough for a single fetch() call, still zero-cost to decode.
The younger Tuareg picks up the elder's grid drawing in the sand. So each star has a row in the catalog — a position and a brightness. And we sort them by brightness so we can stop when we have enough.
Exactly, the elder says. A chart that shows magnitude 6 does not need to load magnitude 12. And a chart of the southern sky does not need northern stars. The catalog is not a list — it is a map with doors. You enter through the door you need.
He stands and looks up. Fifty stars are visible overhead, but he can name a dozen of them. Behind each name is a number — a position precise to a fraction of a degree, measured by instruments he has never seen, confirmed by a satellite he has never heard of, and now held in a column of bytes that would mean nothing to him and everything to the machine.
We have the coordinates. We have the time. We have the projections. We have the catalog. Now we need a language for drawing — a set of marks that any renderer can understand.
Next
In the next article we design the scene graph — an intermediate representation of simple drawing primitives that decouples chart data from rendering, allowing the same star positions to be drawn on Canvas, SVG, or any other output.
Do you like this article? I am building it with org-press, a project that allows you to create notebooks with live code examples. Check it out.
If this resonates.
I make these as gifts for births, anniversaries, moments worth keeping. I am considering opening this practice more widely, carefully and in small numbers.
Each constellation is a 25×25 cm slate plaque, laser-engraved and painted by hand in gold, boxed in carved larch. Around six hours of focused work go into each piece. Indicative price, €150 to €200.
Because each piece takes real time, I ask for a short motivation. I prefer to make them for people who genuinely care about the moment they are marking. Any profit beyond materials and time will go to a children's association, with full transparency.
If enough thoughtful interest gathers, I may open a limited number of commissions.