The Celestial Sphere

Engraving stars for unique moments This series shares my journey learning to draw skies on tile — star charts I engrave for newborns of friends and family, capturing their birth sky. I'm currently exploring selling these, but they're not publicly available for orders.
If you're interested, ask here.

This is the first article in a series converting starplot from Python to JavaScript. Each article is a working module; the code blocks compose into a real library.

Somewhere in the Sahara, two Tuareg sit on the cooling sand. The campfire has burned to embers. Above them, the sky is absurd with stars, so many that the darkness between them seems like the exception, not the rule.

The older one, face half-hidden behind an indigo tagelmust, points at a star low on the horizon. He traces a line from it to another, higher up, then sweeps his hand toward a faint band of light. He is not naming constellations for poetry. He is calculating. The direction to the next well is between where that star rises and where that one will be in two hours.

The Kel Tamasheq have navigated the featureless desert by stars for millennia. They call the stars titritin. Polaris, the one that does not move, anchors the north. Sirius, the brightest, marks the season. The Pleiades, Amanar, announce the rains.

What they do by instinct and inherited knowledge, we are going to do with math. The same math, as it turns out, formalized by Islamic Golden Age astronomers who lived in these same latitudes, who turned desert intuition and Greek observational astronomy into the spherical trigonometry we still use today.

What Is a Coordinate System?

A coordinate system is a way to describe the position of something using numbers. On Earth, we use latitude and longitude: two values that uniquely identify any point on the surface. Celestial coordinate systems do the same thing, but for the sky. They answer the question: given a star, how do I describe where it is so that someone else can find it?

There are two coordinate systems that matter for star charting. One describes where a star sits on the celestial sphere (a fixed, universal address). The other describes where it appears to you, from your location, at this moment.

The Celestial Sphere

Imagine extending the Earth infinitely outward: the poles stretch to become the celestial poles, the equator becomes the celestial equator, and every star is pinned to the inner surface of a vast sphere surrounding us. This is the celestial sphere — not a physical object, but a mental model that has served astronomers for over two thousand years.

The sphere is a convenient fiction. Stars are at vastly different distances, but for the purpose of directions (which is all a chart needs), we can pretend they are all painted on the same shell. Two angles are enough to address any point on a sphere: one measuring how far up or down from the equator, one measuring how far around. The two coordinate systems we need are just two different choices of "equator" and "starting point."

Before we can illustrate these systems, we need tools to draw on a sphere. The two code blocks below build a small canvas drawing library — low-level primitives first, then an orthographic sphere renderer. These are reusable throughout the series; expand them to see the source.

Drawing Helpers — canvas primitives
/** Stroke a circle on a canvas context */
export function strokeCircle(
  ctx: CanvasRenderingContext2D,
  x: number, y: number, r: number,
  style: string, lineWidth = 1,
) {
  ctx.strokeStyle = style;
  ctx.lineWidth = lineWidth;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.stroke();
}

/** Fill a circle on a canvas context */
export function fillCircle(
  ctx: CanvasRenderingContext2D,
  x: number, y: number, r: number,
  style: string,
) {
  ctx.fillStyle = style;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fill();
}

/** Draw a labeled point with a small dot and text */
export function labeledPoint(
  ctx: CanvasRenderingContext2D,
  x: number, y: number,
  label: string,
  color: string,
  opts: { dotSize?: number; font?: string; offsetX?: number; offsetY?: number } = {},
) {
  const { dotSize = 3, font = "10px monospace", offsetX = 6, offsetY = 3 } = opts;
  fillCircle(ctx, x, y, dotSize, color);
  ctx.fillStyle = color;
  ctx.font = font;
  ctx.textAlign = "left";
  ctx.fillText(label, x + offsetX, y + offsetY);
}

/**
 * Draw an arc between two angles on a circle.
 * Angles in radians, measured from positive x-axis.
 */
export function drawArc(
  ctx: CanvasRenderingContext2D,
  cx: number, cy: number, r: number,
  startAngle: number, endAngle: number,
  style: string, lineWidth = 1.5,
  dash: number[] = [],
) {
  ctx.strokeStyle = style;
  ctx.lineWidth = lineWidth;
  ctx.setLineDash(dash);
  ctx.beginPath();
  ctx.arc(cx, cy, r, startAngle, endAngle);
  ctx.stroke();
  ctx.setLineDash([]);
}

/** Clear and fill the canvas with a solid color */
export function clearCanvas(
  ctx: CanvasRenderingContext2D,
  w: number, h: number,
  color = "#111",
) {
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, w, h);
}
Orthographic Sphere Projection — globe renderer

An orthographic projection renders a sphere as it would appear from infinite distance — a "globe" view. The math is simple: given a point's latitude and longitude on the sphere, and a viewpoint, project to 2D screen coordinates. Points on the far side of the sphere (not visible) are flagged. This is a preview of the projection math we will formalize in article 03.

import { fillCircle, strokeCircle, drawArc } from './01-coordinates.org?name=draw-helpers';

const DEG = Math.PI / 180;

export interface SpherePoint {
  x: number;
  y: number;
  visible: boolean;
}

/**
 * Orthographic projection of a point on a unit sphere.
 *
 *   x = cos(lat) * sin(lon - lon0)
 *   y = cos(lat0) * sin(lat) - sin(lat0) * cos(lat) * cos(lon - lon0)
 *   visible when: sin(lat0)*sin(lat) + cos(lat0)*cos(lat)*cos(lon-lon0) > 0
 *
 * @param lat  Point latitude in degrees
 * @param lon  Point longitude in degrees
 * @param lat0 Viewpoint latitude in degrees
 * @param lon0 Viewpoint longitude in degrees
 * @returns Screen x, y in [-1, 1] range and visibility flag
 */
export function projectOrtho(
  lat: number, lon: number,
  lat0: number, lon0: number,
): SpherePoint {
  const φ = lat * DEG, λ = lon * DEG;
  const φ0 = lat0 * DEG, λ0 = lon0 * DEG;
  const dλ = λ - λ0;

  const cosφ = Math.cos(φ), sinφ = Math.sin(φ);
  const cosφ0 = Math.cos0), sinφ0 = Math.sin0);
  const cosdλ = Math.cos(dλ);

  const x = cosφ * Math.sin(dλ);
  const y = cosφ0 * sinφ - sinφ0 * cosφ * cosdλ;
  const visible = sinφ0 * sinφ + cosφ0 * cosφ * cosdλ > 0;

  return { x, y, visible };
}

export interface SphereOpts {
  cx: number;
  cy: number;
  radius: number;
  viewLat: number;
  viewLon: number;
  gridLat?: number;
  gridLon?: number;
  gridColor?: string;
  outlineColor?: string;
  bgColor?: string;
}

/**
 * Draw a wireframe sphere with latitude/longitude grid.
 */
export function drawSphere(ctx: CanvasRenderingContext2D, opts: SphereOpts) {
  const {
    cx, cy, radius: R,
    viewLat, viewLon,
    gridLat = 30, gridLon = 30,
    gridColor = "#2a2a3a",
    outlineColor = "#555",
    bgColor = "#0a0a14",
  } = opts;

  // Sphere background
  fillCircle(ctx, cx, cy, R, bgColor);

  // Latitude lines
  for (let lat = -90 + gridLat; lat < 90; lat += gridLat) {
    ctx.strokeStyle = lat === 0 ? "#3a3a5a" : gridColor;
    ctx.lineWidth = lat === 0 ? 1.2 : 0.6;
    ctx.beginPath();
    let started = false;
    for (let lon = -180; lon <= 180; lon += 2) {
      const p = projectOrtho(lat, lon, viewLat, viewLon);
      if (!p.visible) { started = false; continue; }
      const sx = cx + p.x * R, sy = cy - p.y * R;
      if (!started) { ctx.moveTo(sx, sy); started = true; }
      else ctx.lineTo(sx, sy);
    }
    ctx.stroke();
  }

  // Longitude lines
  for (let lon = -180; lon < 180; lon += gridLon) {
    ctx.strokeStyle = lon === 0 ? "#3a3a5a" : gridColor;
    ctx.lineWidth = lon === 0 ? 1.2 : 0.6;
    ctx.beginPath();
    let started = false;
    for (let lat = -90; lat <= 90; lat += 2) {
      const p = projectOrtho(lat, lon, viewLat, viewLon);
      if (!p.visible) { started = false; continue; }
      const sx = cx + p.x * R, sy = cy - p.y * R;
      if (!started) { ctx.moveTo(sx, sy); started = true; }
      else ctx.lineTo(sx, sy);
    }
    ctx.stroke();
  }

  // Outline
  strokeCircle(ctx, cx, cy, R, outlineColor, 1.5);
}

/**
 * Project a single point onto the sphere and return screen coords.
 */
export function spherePoint(
  lat: number, lon: number,
  cx: number, cy: number, R: number,
  viewLat: number, viewLon: number,
): SpherePoint & { sx: number; sy: number } {
  const p = projectOrtho(lat, lon, viewLat, viewLon);
  return { ...p, sx: cx + p.x * R, sy: cy - p.y * R };
}

/**
 * Draw an arc on the sphere surface between two points via spherical
 * linear interpolation (slerp).
 */
export function drawSphereArc(
  ctx: CanvasRenderingContext2D,
  lat1: number, lon1: number,
  lat2: number, lon2: number,
  cx: number, cy: number, R: number,
  viewLat: number, viewLon: number,
  style: string, lineWidth = 1.5,
  dash: number[] = [],
  steps = 60,
) {
  // Convert to Cartesian on unit sphere
  const toCart = (lat: number, lon: number) => {
    const φ = lat * DEG, λ = lon * DEG;
    return [Math.cos(φ) * Math.cos(λ), Math.cos(φ) * Math.sin(λ), Math.sin(φ)];
  };
  const [x1, y1, z1] = toCart(lat1, lon1);
  const [x2, y2, z2] = toCart(lat2, lon2);
  const dot = Math.min(1, Math.max(-1, x1 * x2 + y1 * y2 + z1 * z2));
  const omega = Math.acos(dot);
  const sinO = Math.sin(omega);

  ctx.strokeStyle = style;
  ctx.lineWidth = lineWidth;
  ctx.setLineDash(dash);
  ctx.beginPath();
  let started = false;

  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    let xi: number, yi: number, zi: number;
    if (sinO < 1e-10) {
      // Points are nearly coincident or antipodal
      xi = x1; yi = y1; zi = z1;
    } else {
      const fA = Math.sin((1 - t) * omega) / sinO;
      const fB = Math.sin(t * omega) / sinO;
      xi = fA * x1 + fB * x2;
      yi = fA * y1 + fB * y2;
      zi = fA * z1 + fB * z2;
    }
    const lat = Math.asin(zi) / DEG;
    const lon = Math.atan2(yi, xi) / DEG;
    const p = projectOrtho(lat, lon, viewLat, viewLon);
    if (!p.visible) { started = false; continue; }
    const sx = cx + p.x * R, sy = cy - p.y * R;
    if (!started) { ctx.moveTo(sx, sy); started = true; }
    else ctx.lineTo(sx, sy);
  }
  ctx.stroke();
  ctx.setLineDash([]);
}

The celestial sphere with its equatorial grid: the north and south celestial poles extend from Earth's axis, the celestial equator encircles the middle. Right Ascension runs along the equator (like longitude), Declination runs pole to pole (like latitude). A handful of bright stars are plotted at their true coordinates.

import { clearCanvas, labeledPoint, fillCircle } from './01-coordinates.org?name=draw-helpers';
import { drawSphere, spherePoint } from './01-coordinates.org?name=draw-sphere';

const W = 440, H = 440, R = 180;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.style.cssText = "width:100%;max-width:440px;display:block";
const ctx = canvas.getContext("2d")!;
const cx = W / 2, cy = H / 2;

// View: tilted so we see the equator at an angle
const viewLat = 25, viewLon = -20;

clearCanvas(ctx, W, H, "#0a0c14");

// Draw sphere with RA/Dec grid (RA = longitude, Dec = latitude)
drawSphere(ctx, {
  cx, cy, radius: R,
  viewLat, viewLon,
  gridLat: 30,  // Dec lines every 30°
  gridLon: 15,  // RA lines every 15° (= 1h)
  gridColor: "#1e2030",
  outlineColor: "#4a4a5a",
  bgColor: "#0a0c14",
});

// Label poles
const ncp = spherePoint(90, 0, cx, cy, R, viewLat, viewLon);
if (ncp.visible) {
  labeledPoint(ctx, ncp.sx, ncp.sy, "NCP", "#7a9aba", { dotSize: 4, font: "bold 11px monospace" });
}
const scp = spherePoint(-90, 0, cx, cy, R, viewLat, viewLon);
if (scp.visible) {
  labeledPoint(ctx, scp.sx, scp.sy, "SCP", "#7a9aba", { dotSize: 4, font: "bold 11px monospace" });
}

// Label equator
const eqLabel = spherePoint(0, 40, cx, cy, R, viewLat, viewLon);
if (eqLabel.visible) {
  ctx.fillStyle = "#4a5a7a";
  ctx.font = "10px monospace";
  ctx.textAlign = "center";
  ctx.fillText("celestial equator", eqLabel.sx, eqLabel.sy + 14);
}

// RA hour labels along equator
for (let h = 0; h < 24; h += 3) {
  const lon = h * 15; // 1h = 15°
  const p = spherePoint(0, lon, cx, cy, R, viewLat, viewLon);
  if (p.visible) {
    ctx.fillStyle = "#5a6a8a";
    ctx.font = "9px monospace";
    ctx.textAlign = "center";
    ctx.fillText(`${h}h`, p.sx, p.sy - 8);
  }
}

// Bright stars at their equatorial positions
// RA in degrees → sphere longitude, Dec → sphere latitude
const stars = [
  { ra: 101.3, dec: -16.7, name: "Sirius", color: "#aac" },
  { ra: 79.2, dec: 46.0, name: "Capella", color: "#dda" },
  { ra: 88.8, dec: 7.4, name: "Betelgeuse", color: "#e96" },
  { ra: 279.2, dec: 38.8, name: "Vega", color: "#aaf" },
  { ra: 37.95, dec: 89.26, name: "Polaris", color: "#ddc" },
  { ra: 297.7, dec: 8.9, name: "Altair", color: "#ddd" },
];

for (const star of stars) {
  const p = spherePoint(star.dec, star.ra, cx, cy, R, viewLat, viewLon);
  if (p.visible) {
    // Glow
    const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, 10);
    grad.addColorStop(0, "rgba(255,255,220,0.15)");
    grad.addColorStop(1, "rgba(255,255,220,0)");
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(p.sx, p.sy, 10, 0, Math.PI * 2); ctx.fill();

    labeledPoint(ctx, p.sx, p.sy, star.name, star.color, { dotSize: 3 });
  }
}

export default canvas;

Equatorial Coordinates (RA/Dec)

Imagine the Earth's latitude/longitude grid projected outward onto the sky. That is the equatorial coordinate system. It is fixed to the stars, not to the observer. A star's equatorial coordinates do not change as the Earth rotates (they only drift very slowly over centuries due to precession).

  • Right Ascension (RA) is the sky's equivalent of longitude, measured in degrees (0 to 360) or hours (0h to 24h).
  • Declination (Dec) is the sky's equivalent of latitude, measured in degrees from -90 (south celestial pole) to +90 (north celestial pole).

When our Tuareg elder says a star is there, in that permanent place on the sphere, he means its equatorial coordinates, even if he would not use those words.

Hipparchus of Nicaea (~190–120 BCE) created the first known star catalog with ~850 stars positioned on the celestial sphere, documented in his lost work and preserved through Ptolemy's Almagest. His coordinate system, refined over two millennia, is what we still use. The equatorial grid was fixed to the vernal equinox (the point where the Sun crosses the celestial equator in spring), giving us a stable reference frame independent of where you stand on Earth.

In TypeScript, we represent this as an interface with two fields:

/** Equatorial coordinate — fixed on the celestial sphere */
export interface EquatorialCoord {
  /** Right ascension in degrees (0–360) */
  ra: number;
  /** Declination in degrees (-90 to +90) */
  dec: number;
}

Measuring RA and Dec

The sphere below shows how RA and Dec locate a single star. Right Ascension is measured along the celestial equator from the vernal equinox (0h). Declination is measured along a great circle from the equator toward the pole. The colored arcs show the two measurements.

import { clearCanvas, fillCircle, labeledPoint } from './01-coordinates.org?name=draw-helpers';
import { drawSphere, spherePoint, drawSphereArc } from './01-coordinates.org?name=draw-sphere';

const W = 440, H = 440, R = 180;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.style.cssText = "width:100%;max-width:440px;display:block";
const ctx = canvas.getContext("2d")!;
const cx = W / 2, cy = H / 2;

const viewLat = 25, viewLon = -20;

clearCanvas(ctx, W, H, "#0a0c14");

drawSphere(ctx, {
  cx, cy, radius: R,
  viewLat, viewLon,
  gridLat: 30, gridLon: 15,
  gridColor: "#161828",
  outlineColor: "#4a4a5a",
  bgColor: "#0a0c14",
});

// Target star: Capella (RA ~79.2° = 5h 17m, Dec ~46°)
const starRA = 79.2, starDec = 46.0;

// RA arc: along equator from 0° to starRA
drawSphereArc(ctx, 0, 0, 0, starRA, cx, cy, R, viewLat, viewLon,
  "#c87040", 2.5, [], 80);

// Dec arc: along the hour circle from equator to star
drawSphereArc(ctx, 0, starRA, starDec, starRA, cx, cy, R, viewLat, viewLon,
  "#4080c8", 2.5, [], 40);

// Vernal equinox marker
const ve = spherePoint(0, 0, cx, cy, R, viewLat, viewLon);
if (ve.visible) {
  labeledPoint(ctx, ve.sx, ve.sy, "♈ 0h", "#c87040", { dotSize: 4, font: "bold 11px monospace" });
}

// RA/equator intersection
const raEnd = spherePoint(0, starRA, cx, cy, R, viewLat, viewLon);
if (raEnd.visible) {
  fillCircle(ctx, raEnd.sx, raEnd.sy, 3, "#c87040");
}

// Star
const starP = spherePoint(starDec, starRA, cx, cy, R, viewLat, viewLon);
if (starP.visible) {
  const grad = ctx.createRadialGradient(starP.sx, starP.sy, 0, starP.sx, starP.sy, 12);
  grad.addColorStop(0, "rgba(255,220,150,0.2)");
  grad.addColorStop(1, "rgba(255,220,150,0)");
  ctx.fillStyle = grad;
  ctx.beginPath(); ctx.arc(starP.sx, starP.sy, 12, 0, Math.PI * 2); ctx.fill();

  labeledPoint(ctx, starP.sx, starP.sy, "Capella", "#dda", { dotSize: 4, font: "bold 11px monospace" });
}

// NCP label
const ncp = spherePoint(90, 0, cx, cy, R, viewLat, viewLon);
if (ncp.visible) {
  labeledPoint(ctx, ncp.sx, ncp.sy, "NCP", "#7a9aba", { dotSize: 3, font: "10px monospace" });
}

// Legend
ctx.font = "11px monospace";
ctx.textAlign = "left";
ctx.fillStyle = "#c87040";
ctx.fillText("— RA = 5h 17m (79.2°)", 12, H - 30);
ctx.fillStyle = "#4080c8";
ctx.fillText("— Dec = +46°", 12, H - 14);

export default canvas;

Horizontal Coordinates (Alt/Az)

The equatorial system tells you where a star lives on the celestial sphere, but not where to look when you step outside. For that you need the horizontal coordinate system, which is relative to the observer.

  • Altitude (Alt) is the angle above the horizon: 0° means on the horizon, 90° means directly overhead (the zenith).
  • Azimuth (Az) is the compass direction: 0° is north, 90° is east, 180° is south, 270° is west.

The catch: horizontal coordinates change constantly. As the Earth rotates, a star's altitude and azimuth shift. The same star that is 30° above the eastern horizon at 9 PM will be 60° above the southern horizon at midnight. Converting between equatorial and horizontal is the core problem of observational astronomy.

/** Horizontal coordinate — relative to observer's position */
export interface HorizontalCoord {
  /** Altitude in degrees — angle above horizon (0 = horizon, 90 = zenith) */
  alt: number;
  /** Azimuth in degrees — compass direction (0 = North, 90 = East, 180 = South) */
  az: number;
}

The Observer's Dome

Looking at the observer's sky dome from outside. Altitude rings run parallel to the horizon; azimuth lines radiate from the zenith like compass spokes. The star's position is measured as an angle up from the horizon (altitude) and a compass bearing from north (azimuth).

import { clearCanvas, fillCircle, labeledPoint, strokeCircle, drawArc } from './01-coordinates.org?name=draw-helpers';
import { drawSphere, spherePoint, drawSphereArc } from './01-coordinates.org?name=draw-sphere';

const W = 440, H = 440, R = 180;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.style.cssText = "width:100%;max-width:440px;display:block";
const ctx = canvas.getContext("2d")!;
const cx = W / 2, cy = H / 2;

// View from slightly outside/above — we look at the dome from the east
const viewLat = 30, viewLon = -50;

clearCanvas(ctx, W, H, "#0a0c14");

// Draw dome as a sphere (the observer's local sky hemisphere)
// We use the sphere renderer but conceptually this is the local sky
drawSphere(ctx, {
  cx, cy, radius: R,
  viewLat, viewLon,
  gridLat: 15,  // altitude rings every 15°
  gridLon: 45,  // azimuth lines every 45°
  gridColor: "#1a1e28",
  outlineColor: "#5a4a3a",
  bgColor: "#0a0c14",
});

// Label the horizon (equator of this sphere = horizon)
const horizonLabel = spherePoint(0, 20, cx, cy, R, viewLat, viewLon);
if (horizonLabel.visible) {
  ctx.fillStyle = "#5a4a3a";
  ctx.font = "10px monospace";
  ctx.textAlign = "center";
  ctx.fillText("horizon", horizonLabel.sx, horizonLabel.sy + 14);
}

// Cardinal directions on the horizon
const cardinals: [string, number][] = [["N", 0], ["E", 90], ["S", 180], ["W", 270]];
for (const [label, az] of cardinals) {
  const p = spherePoint(0, az, cx, cy, R, viewLat, viewLon);
  if (p.visible) {
    ctx.fillStyle = "#887766";
    ctx.font = "bold 11px monospace";
    ctx.textAlign = "center";
    ctx.fillText(label, p.sx, p.sy + (p.sy > cy ? 16 : -8));
  }
}

// Zenith
const zenith = spherePoint(90, 0, cx, cy, R, viewLat, viewLon);
if (zenith.visible) {
  labeledPoint(ctx, zenith.sx, zenith.sy, "zenith", "#7a8a6a", { dotSize: 4, font: "bold 11px monospace" });
}

// A star: Alt 55°, Az 135° (SE, moderately high)
const starAlt = 55, starAz = 135;
const starP = spherePoint(starAlt, starAz, cx, cy, R, viewLat, viewLon);
if (starP.visible) {
  const grad = ctx.createRadialGradient(starP.sx, starP.sy, 0, starP.sx, starP.sy, 12);
  grad.addColorStop(0, "rgba(255,220,150,0.2)");
  grad.addColorStop(1, "rgba(255,220,150,0)");
  ctx.fillStyle = grad;
  ctx.beginPath(); ctx.arc(starP.sx, starP.sy, 12, 0, Math.PI * 2); ctx.fill();

  labeledPoint(ctx, starP.sx, starP.sy, "★", "#ffd", { dotSize: 4, font: "bold 14px monospace" });
}

// Azimuth arc: along horizon from N (0°) to star's azimuth
drawSphereArc(ctx, 0, 0, 0, starAz, cx, cy, R, viewLat, viewLon,
  "#c87040", 2.5, [], 60);

// Altitude arc: from horizon up to star along the azimuth line
drawSphereArc(ctx, 0, starAz, starAlt, starAz, cx, cy, R, viewLat, viewLon,
  "#40a070", 2.5, [], 40);

// Altitude rings labeled
for (const alt of [30, 60]) {
  const p = spherePoint(alt, 0, cx, cy, R, viewLat, viewLon);
  if (p.visible) {
    ctx.fillStyle = "#445";
    ctx.font = "9px monospace";
    ctx.textAlign = "left";
    ctx.fillText(`${alt}°`, p.sx + 4, p.sy - 4);
  }
}

// Legend
ctx.font = "11px monospace";
ctx.textAlign = "left";
ctx.fillStyle = "#c87040";
ctx.fillText("— Az = 135° (SE)", 12, H - 30);
ctx.fillStyle = "#40a070";
ctx.fillText("— Alt = 55°", 12, H - 14);

export default canvas;

The Observer

To convert between these two systems, we need to know who is observing, where they are, and when. This is the observer:

/** Observer's position and time */
export interface Observer {
  /** Latitude in degrees (-90 to +90) */
  lat: number;
  /** Longitude in degrees (-180 to +180) */
  lon: number;
  /** Observation time */
  datetime: Date;
}

For the rest of this article (and the modules that depend on it), we bundle all three types in a single named block:

export * from './01-coordinates.org?name=equatorial-type';
export * from './01-coordinates.org?name=horizontal-type';
export * from './01-coordinates.org?name=observer-type';

Angle Utilities

Trigonometric functions in JavaScript use radians, but astronomical coordinates are in degrees. We need conversions, and a function to normalize angles into a range.

const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;

export function degToRad(deg: number): number {
  return deg * DEG2RAD;
}

export function radToDeg(rad: number): number {
  return rad * RAD2DEG;
}

/** Normalize an angle to [0, 360) */
export function normalizeDeg(deg: number): number {
  return ((deg % 360) + 360) % 360;
}

/** Normalize an angle to [0, 2*PI) */
export function normalizeRad(rad: number): number {
  const TWO_PI = 2 * Math.PI;
  return ((rad % TWO_PI) + TWO_PI) % TWO_PI;
}

RA Display Formatting

Right Ascension is traditionally displayed in hours, minutes, and seconds rather than degrees. This convention comes from the connection between RA and sidereal time: one hour of RA equals 15 degrees of Earth rotation. So 360° = 24h, and the sky completes one full rotation in one sidereal day.

/**
 * Convert RA in degrees to hours/minutes/seconds.
 *
 *   360deg = 24h, so 1h = 15deg
 *   1h = 60m, 1m = 60s
 */
export function raToHms(raDeg: number): { h: number; m: number; s: number } {
  const totalHours = raDeg / 15;
  const h = Math.floor(totalHours);
  const remainder = (totalHours - h) * 60;
  const m = Math.floor(remainder);
  const s = Math.round((remainder - m) * 60);
  return { h: h % 24, m, s };
}

/** Format RA as "HHh MMm SSs" */
export function formatRA(raDeg: number): string {
  const { h, m, s } = raToHms(raDeg);
  return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
}

/** Format Dec as "+DD deg MM' SS\"" */
export function formatDec(decDeg: number): string {
  const sign = decDeg >= 0 ? "+" : "-";
  const abs = Math.abs(decDeg);
  const d = Math.floor(abs);
  const remainder = (abs - d) * 60;
  const m = Math.floor(remainder);
  const s = Math.round((remainder - m) * 60);
  return `${sign}${d}\u00B0 ${String(m).padStart(2, "0")}' ${String(s).padStart(2, "0")}"`;
}

Two Views of the Same Sky

The equatorial and horizontal systems describe the same stars from two different perspectives. The sphere on the left shows the celestial sphere — fixed, universal, the same for everyone. The dome on the right shows how those same stars appear to an observer standing at a particular place and time.

The colors link corresponding stars across the two views. Every star has one permanent address on the celestial sphere (its RA/Dec), but its position in your sky (Alt/Az) changes with every tick of the clock.

import { clearCanvas, fillCircle, labeledPoint, strokeCircle } from './01-coordinates.org?name=draw-helpers';
import { drawSphere, spherePoint } from './01-coordinates.org?name=draw-sphere';

const W = 880, H = 420;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
canvas.style.cssText = "width:100%;max-width:880px;display:block";
const ctx = canvas.getContext("2d")!;

const R = 170;
const cx1 = W / 4 + 10, cy1 = H / 2;      // Left: celestial sphere
const cx2 = 3 * W / 4 - 10, cy2 = H / 2;  // Right: observer dome

// Observer: Rome, Feb 7 2026, 21:00 UTC
const obsLat = 41.9028, obsLon = 12.4964;

// Stars with colors for linking
const stars = [
  { ra: 101.3, dec: -16.7, name: "Sirius", color: "#66aaff" },
  { ra: 79.2, dec: 46.0, name: "Capella", color: "#ffaa66" },
  { ra: 88.8, dec: 7.4, name: "Betelgeuse", color: "#ff6644" },
  { ra: 279.2, dec: 38.8, name: "Vega", color: "#aa66ff" },
  { ra: 37.95, dec: 89.26, name: "Polaris", color: "#66ffaa" },
  { ra: 297.7, dec: 8.9, name: "Altair", color: "#ffff66" },
];

// Simple eq→horizontal for this static illustration
// (we inline the math since we don't import from article 02 yet)
function simpleEqToHz(ra: number, dec: number, lat: number, lst: number) {
  const D = Math.PI / 180;
  const ha = (lst - ra) * D;
  const d = dec * D, l = lat * D;
  const sinAlt = Math.sin(d) * Math.sin(l) + Math.cos(d) * Math.cos(l) * Math.cos(ha);
  const alt = Math.asin(sinAlt);
  const cosAlt = Math.cos(alt);
  const sinAz = -Math.cos(d) * Math.sin(ha) / cosAlt;
  const cosAz = (Math.sin(d) - Math.sin(l) * sinAlt) / (Math.cos(l) * cosAlt);
  const az = Math.atan2(sinAz, cosAz);
  return { alt: alt / D, az: ((az / D) % 360 + 360) % 360 };
}

// Compute GMST and LST inline
function computeLST(): number {
  const date = new Date("2026-02-07T21:00:00Z");
  const jd = date.getTime() / 86400000 + 2440587.5;
  const T = (jd - 2451545.0) / 36525;
  const d = jd - 2451545.0;
  const theta = 280.46061837 + 360.98564736629 * d + 0.000387933 * T * T - T * T * T / 38710000;
  return ((theta + obsLon) % 360 + 360) % 360;
}

const lst = computeLST();

clearCanvas(ctx, W, H, "#0a0c14");

// === LEFT: Celestial sphere ===
const sphereViewLat = 25, sphereViewLon = -20;

drawSphere(ctx, {
  cx: cx1, cy: cy1, radius: R,
  viewLat: sphereViewLat, viewLon: sphereViewLon,
  gridLat: 30, gridLon: 30,
  gridColor: "#161828",
  outlineColor: "#4a4a5a",
  bgColor: "#0a0c14",
});

ctx.fillStyle = "#5a6a8a";
ctx.font = "bold 12px monospace";
ctx.textAlign = "center";
ctx.fillText("Celestial Sphere (RA/Dec)", cx1, 18);

// Stars on celestial sphere
for (const star of stars) {
  const p = spherePoint(star.dec, star.ra, cx1, cy1, R, sphereViewLat, sphereViewLon);
  if (p.visible) {
    const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, 8);
    grad.addColorStop(0, star.color + "44");
    grad.addColorStop(1, star.color + "00");
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(p.sx, p.sy, 8, 0, Math.PI * 2); ctx.fill();

    labeledPoint(ctx, p.sx, p.sy, star.name, star.color, { dotSize: 3, font: "10px monospace" });
  }
}

// === RIGHT: Observer dome (alt/az as sphere) ===
const domeViewLat = 30, domeViewLon = -50;

drawSphere(ctx, {
  cx: cx2, cy: cy2, radius: R,
  viewLat: domeViewLat, viewLon: domeViewLon,
  gridLat: 30, gridLon: 45,
  gridColor: "#1a1e28",
  outlineColor: "#5a4a3a",
  bgColor: "#0a0c14",
});

ctx.fillStyle = "#8a7a6a";
ctx.font = "bold 12px monospace";
ctx.textAlign = "center";
ctx.fillText("Observer's Dome (Alt/Az)", cx2, 18);

// Cardinals
const cardinals: [string, number][] = [["N", 0], ["E", 90], ["S", 180], ["W", 270]];
for (const [label, az] of cardinals) {
  const p = spherePoint(0, az, cx2, cy2, R, domeViewLat, domeViewLon);
  if (p.visible) {
    ctx.fillStyle = "#776";
    ctx.font = "bold 10px monospace";
    ctx.textAlign = "center";
    ctx.fillText(label, p.sx, p.sy + (p.sy > cy2 ? 14 : -6));
  }
}

// Stars on observer dome
for (const star of stars) {
  const hz = simpleEqToHz(star.ra, star.dec, obsLat, lst);
  if (hz.alt < 0) continue;
  const p = spherePoint(hz.alt, hz.az, cx2, cy2, R, domeViewLat, domeViewLon);
  if (p.visible) {
    const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, 8);
    grad.addColorStop(0, star.color + "44");
    grad.addColorStop(1, star.color + "00");
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(p.sx, p.sy, 8, 0, Math.PI * 2); ctx.fill();

    labeledPoint(ctx, p.sx, p.sy, star.name, star.color, { dotSize: 3, font: "10px monospace" });
  }
}

// Connecting line label
ctx.fillStyle = "#555";
ctx.font = "10px monospace";
ctx.textAlign = "center";
ctx.fillText("same stars, different coordinates", W / 2, H - 10);
ctx.fillText("Rome, 2026-02-07 21:00 UTC", W / 2, H - 24);

export default canvas;

The younger Tuareg sits up. He understands now: every star has a permanent address on the great sphere, and a fleeting position in his personal sky. The first is eternal — he could tell someone on the other side of the world where to find Sirius, and they would agree. The second depends on where he stands, and when.

You know the places, the elder says. Now you need the time. The sky is a clock, but it keeps a different hour than the sun.

Next

In the next article we add sidereal time, hour angles, and the conversions that turn fixed celestial addresses into positions in your local sky.


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.

By submitting you agree to our privacy policy.