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.

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.

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).

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.

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.

Currently gifting, not selling

I make these as gifts for special occasions — births, anniversaries, moments worth keeping.

Each constellation is a 25×25 cm slate plaque — laser-engraved, gold-painted by hand, boxed in carved larch. Around 6 hours of work per tile. Indicative price: €150 – €200.

This is not IKEA art. I ask for a short motivation because each piece takes real time, and I want to make them for people who genuinely care. Extra profits go to a children's association — I'm committed to full transparency on that.