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 fourth article in a series converting starplot from Python to JavaScript. We build two more projections — Mercator (cylindrical, conformal) and Mollweide (pseudo-cylindrical, equal-area) — and assemble a registry of all four.

At an oasis town, the Tuareg elder and the younger man sit in a merchant's house. A Genoese trader unrolls a rectangular sea chart on the table. Lines crisscross it at precise angles — rhumb lines, the merchant explains. Follow any line on this chart with a compass, and you will reach your destination. The heading never changes.

The younger Tuareg traces the map's edges. Greenland looks as large as Africa. The merchant shrugs. That is the price. Angles are true, but areas lie.

The elder studies the chart. Can we do this for the stars?

Wrapping the Sphere

The azimuthal projections from the previous article look at the sphere from a single point. They show one region well and cut off the rest. To show the entire sky on a single chart, we need a different strategy.

Imagine wrapping a cylinder around the sphere, tangent at the equator. Project every point outward onto the cylinder, then unroll it into a flat rectangle. This is a cylindrical projection. The result maps the full 360° of longitude to the x-axis and the full range of latitude to the y-axis — the whole sphere on one chart.

The simplest cylindrical projection is equirectangular: x = longitude, y = latitude. It is used when simplicity matters more than accuracy. But for navigation, we need something that preserves angles. For astronomy, we need something that preserves areas. These two requirements lead to Mercator and Mollweide.

Mercator Projection

The merchant traces a line on his chart between two ports. I set my compass to this angle and never adjust. The line is straight on the chart and constant on the compass. That is all a sailor needs.

The elder nods slowly. But your Greenland is a lie.

Every map is a lie, the merchant replies. This one lies about size so it can tell the truth about direction.

The Mercator projection is the most famous cylindrical projection. It is conformal: angles and local shapes are preserved everywhere. Rhumb lines (paths of constant compass bearing) appear as straight lines, which made it indispensable for navigation.

The math stretches latitude spacing further and further from the equator, reaching infinity at the poles:

x = λ - λ₀

y = ln[tan(π/4 + φ/2)]

We clamp latitude to ±85° to avoid the singularity at the poles.

import type { ProjectionFn, ProjectedPoint } from './03-projections.org?name=projection-types';

const DEG = Math.PI / 180;
const MAX_LAT = 85;

/**
 * Mercator projection.
 *
 * Conformal cylindrical projection. Angles are preserved everywhere.
 * Rhumb lines (constant compass bearing) are straight lines.
 * Areas are increasingly distorted toward the poles.
 *
 * The y-axis uses the Mercator formula:
 *   y = ln[tan(π/4 + φ/2)]
 * which stretches polar regions to infinity.
 * We clamp to ±85° latitude.
 */
export const mercator: ProjectionFn = (
  lat: number, lon: number,
  lat0: number, lon0: number,
): ProjectedPoint => {
  const clampedLat = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
  const clampedLat0 = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat0));

  const φ = clampedLat * DEG;
  const φ0 = clampedLat0 * DEG;

  let dλ = (lon - lon0) * DEG;
  // Normalize to [-π, π]
  while (dλ > Math.PI) dλ -= 2 * Math.PI;
  while (dλ < -Math.PI) dλ += 2 * Math.PI;

  const x = dλ;
  const y = Math.log(Math.tan(Math.PI / 4 + φ / 2))
          - Math.log(Math.tan(Math.PI / 4 + φ0 / 2));

  return { x, y, visible: true };
};

Gerard Mercator published his world map in 1569, but never revealed the mathematical formula behind it. It was Edward Wright who, in 1599, published the first correct mathematical description: the integral of the secant function. The formula y = ln[tan(π/4 + φ/2)] was not derived until later — it is equivalent to Wright's tabulated values but expressed in closed form using logarithms.

Drag to shift the center of the Mercator projection. Notice how the grid squares near the equator are roughly equal-sized, but stretch dramatically toward the poles. Stars near the polar regions appear far apart even if they are close on the actual sphere.

import { mercator } from './04-projections-2.org?name=mercator';
import { clearCanvas, fillCircle } from './01-coordinates.org?name=draw-helpers';

const W = 640, H = 400;
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 = 90;

let centerLat = 0, centerLon = 0;
let dragging = false, lastX = 0, lastY = 0;

const stars = [
  { ra: 101.3, dec: -16.7, name: "Sirius" },
  { ra: 79.2, dec: 46.0, name: "Capella" },
  { ra: 88.8, dec: 7.4, name: "Betelgeuse" },
  { ra: 78.6, dec: -8.2, name: "Rigel" },
  { ra: 114.8, dec: 5.2, name: "Procyon" },
  { ra: 279.2, dec: 38.8, name: "Vega" },
  { ra: 297.7, dec: 8.9, name: "Altair" },
  { ra: 310.4, dec: 45.3, name: "Deneb" },
  { ra: 37.95, dec: 89.26, name: "Polaris" },
  { ra: 213.9, dec: 19.2, name: "Arcturus" },
  { ra: 201.3, dec: -11.2, name: "Spica" },
  { ra: 247.4, dec: -26.4, name: "Antares" },
];

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

  // Latitude lines
  for (let lat = -80; lat <= 80; lat += 20) {
    ctx.strokeStyle = lat === 0 ? "#3a3a5a" : "#1e2030";
    ctx.lineWidth = lat === 0 ? 1.2 : 0.6;
    ctx.beginPath();
    let started = false;
    for (let lon = -180; lon <= 180; lon += 2) {
      const p = mercator(lat, lon, centerLat, centerLon);
      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();
  }

  // Longitude lines
  for (let lon = -180; lon < 180; lon += 20) {
    ctx.strokeStyle = lon === 0 ? "#3a3a5a" : "#1e2030";
    ctx.lineWidth = lon === 0 ? 1.2 : 0.6;
    ctx.beginPath();
    let started = false;
    for (let lat = -85; lat <= 85; lat += 2) {
      const p = mercator(lat, lon, centerLat, centerLon);
      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();
  }

  // Border
  ctx.strokeStyle = "#4a4a5a";
  ctx.lineWidth = 1.5;
  ctx.strokeRect(10, 10, W - 20, H - 20);

  // Stars
  for (const star of stars) {
    const p = mercator(star.dec, star.ra, centerLat, centerLon);
    const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;
    if (sx < 10 || sx > W - 10 || sy < 10 || sy > H - 10) continue;

    const grad = ctx.createRadialGradient(sx, sy, 0, sx, 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(sx, sy, 10, 0, Math.PI * 2); ctx.fill();

    fillCircle(ctx, sx, sy, 3, "#ffd");
    ctx.fillStyle = "#887";
    ctx.font = "10px monospace";
    ctx.textAlign = "left";
    ctx.fillText(star.name, sx + 6, sy + 3);
  }

  // Info
  ctx.fillStyle = "#666";
  ctx.font = "11px monospace";
  ctx.textAlign = "left";
  ctx.fillText(`Mercator  center: ${centerLat.toFixed(0)}°, ${centerLon.toFixed(0)}°`, 14, H - 16);
  ctx.textAlign = "right";
  ctx.fillText("drag to pan", 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.5;
  centerLat = Math.max(-80, Math.min(80, centerLat + dy * 0.5));
  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("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.5;
  centerLat = Math.max(-80, Math.min(80, centerLat + dy * 0.5));
  lastX = t.clientX; lastY = t.clientY;
  draw();
});
canvas.addEventListener("touchend", () => { dragging = false; });

draw();
export default canvas;

The Area Problem

Mercator distorts areas enormously: at 60° latitude, a region appears four times larger than it is. At 85°, it appears over a hundred times larger. For navigation this is acceptable — sailors care about direction, not area. But for astronomy, area distortion is a serious problem.

When mapping galaxy surveys, the cosmic microwave background, or stellar density across the sky, equal areas on the chart must correspond to equal areas on the sphere. A cluster of galaxies near the celestial pole should take up the same chart area as an identical cluster near the equator. This requirement leads us to equal-area projections.

Mollweide Projection

The elder draws an oval in the sand with a stick. Inside it, he sketches the curves of latitude, bowing outward from a straight equator. This shows the entire sky, he says. And unlike the merchant's chart, areas are true. A patch of sky near the pole takes up the same space on this map as the same patch near the equator.

The younger Tuareg looks at the oval. But the shapes are wrong — the constellations near the edges look squeezed.

That is the price, the elder says. True area, distorted shape. Every projection chooses.

The Mollweide projection (also called the homolographic or Babinet projection) maps the entire sphere into an ellipse with axes in ratio 2:1. It is equal-area: the ratio of any two regions on the map exactly matches their ratio on the sphere. This makes it the standard for all-sky astronomical surveys.

The math requires solving a transcendental equation for an auxiliary angle θ:

Solve: 2θ + sin(2θ) = π sin(φ)

Then: x = (2√2 / π)(λ - λ₀) cos θ

And: y = √2 sin θ

The equation 2θ + sin(2θ) = π sin(φ) has no closed-form solution. We solve it with Newton-Raphson iteration.

import type { ProjectionFn, ProjectedPoint } from './03-projections.org?name=projection-types';

const DEG = Math.PI / 180;

/**
 * Solve 2θ + sin(2θ) = π sin(φ) for θ using Newton-Raphson.
 *
 * The function f(θ) = 2θ + sin(2θ) - π sin(φ) has derivative
 * f'(θ) = 2 + 2cos(2θ).
 *
 * Newton-Raphson: θ_{n+1} = θ_n - f(θ_n) / f'(θ_n)
 *
 * Converges in 2-5 iterations for most latitudes.
 * At the poles (φ = ±90°), θ = ±π/2 exactly.
 */
export function mollweideTheta(lat: number): number {
  const φ = lat * DEG;

  // Special case: poles
  if (Math.abs(lat) >= 89.999) return Math.sign(lat) * Math.PI / 2;

  const target = Math.PI * Math.sin(φ);
  let θ = φ; // initial guess

  for (let i = 0; i < 20; i++) {
    const f = 2 * θ + Math.sin(2 * θ) - target;
    const fPrime = 2 + 2 * Math.cos(2 * θ);

    if (Math.abs(fPrime) < 1e-12) break;
    const delta = f / fPrime;
    θ -= delta;

    if (Math.abs(delta) < 1e-10) break;
  }

  return θ;
}

/**
 * Mollweide (equal-area) projection.
 *
 * Maps the entire sphere into a 2:1 ellipse.
 * Areas are preserved exactly. Shapes are distorted,
 * especially near the edges.
 *
 * Used for all-sky astronomical surveys (WMAP, Planck CMB maps)
 * and galaxy distribution maps.
 */
export const mollweide: ProjectionFn = (
  lat: number, lon: number,
  lat0: number, lon0: number,
): ProjectedPoint => {
  const θ = mollweideTheta(lat);

  let dλ = (lon - lon0) * DEG;
  while (dλ > Math.PI) dλ -= 2 * Math.PI;
  while (dλ < -Math.PI) dλ += 2 * Math.PI;

  const SQRT2 = Math.SQRT2;
  const x = (2 * SQRT2 / Math.PI) * dλ * Math.cos(θ);
  const y = SQRT2 * Math.sin(θ);

  return { x, y, visible: true };
};

Karl Mollweide published this projection in 1805 in Leipzig. The equal-area property made it the standard for astronomical all-sky maps. The WMAP and Planck missions both used Mollweide projections for their iconic cosmic microwave background maps. Al-Biruni (973–1048) explored equal-area mappings centuries earlier, though not this specific one, in his work on geodesy and cartography.

The Mollweide projection maps the whole sky into an ellipse. Notice how grid cells near the center are roughly rectangular, but cells near the edges are compressed horizontally — the tradeoff for preserving area.

import { mollweide } from './04-projections-2.org?name=mollweide';
import { clearCanvas, fillCircle } from './01-coordinates.org?name=draw-helpers';

const W = 640, H = 340;
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 = 105;

let centerLon = 0;
let dragging = false, lastX = 0;

const stars = [
  { ra: 101.3, dec: -16.7, name: "Sirius" },
  { ra: 79.2, dec: 46.0, name: "Capella" },
  { ra: 88.8, dec: 7.4, name: "Betelgeuse" },
  { ra: 78.6, dec: -8.2, name: "Rigel" },
  { ra: 114.8, dec: 5.2, name: "Procyon" },
  { ra: 279.2, dec: 38.8, name: "Vega" },
  { ra: 297.7, dec: 8.9, name: "Altair" },
  { ra: 310.4, dec: 45.3, name: "Deneb" },
  { ra: 37.95, dec: 89.26, name: "Polaris" },
  { ra: 213.9, dec: 19.2, name: "Arcturus" },
  { ra: 201.3, dec: -11.2, name: "Spica" },
  { ra: 247.4, dec: -26.4, name: "Antares" },
];

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

  // Draw the ellipse boundary
  // Mollweide ellipse: x range ±2√2, y range ±√2
  const eW = 2 * Math.SQRT2 * SCALE;
  const eH = Math.SQRT2 * SCALE;
  ctx.strokeStyle = "#4a4a5a";
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.ellipse(cx, cy, eW, eH, 0, 0, Math.PI * 2);
  ctx.stroke();

  // Clipping to ellipse
  ctx.save();
  ctx.beginPath();
  ctx.ellipse(cx, cy, eW, eH, 0, 0, Math.PI * 2);
  ctx.clip();

  // Latitude lines
  for (let lat = -80; lat <= 80; lat += 20) {
    ctx.strokeStyle = lat === 0 ? "#3a3a5a" : "#1e2030";
    ctx.lineWidth = lat === 0 ? 1.2 : 0.6;
    ctx.beginPath();
    let started = false;
    for (let lon = -180; lon <= 180; lon += 2) {
      const p = mollweide(lat, lon, 0, centerLon);
      const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;
      if (!started) { ctx.moveTo(sx, sy); started = true; }
      else ctx.lineTo(sx, sy);
    }
    ctx.stroke();
  }

  // Longitude lines
  for (let lon = -180; lon < 180; lon += 20) {
    ctx.strokeStyle = lon === 0 ? "#3a3a5a" : "#1e2030";
    ctx.lineWidth = lon === 0 ? 1.2 : 0.6;
    ctx.beginPath();
    let started = false;
    for (let lat = -90; lat <= 90; lat += 2) {
      const p = mollweide(lat, lon, 0, centerLon);
      const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;
      if (!started) { ctx.moveTo(sx, sy); started = true; }
      else ctx.lineTo(sx, sy);
    }
    ctx.stroke();
  }

  // Stars
  for (const star of stars) {
    const p = mollweide(star.dec, star.ra, 0, centerLon);
    const sx = cx + p.x * SCALE, sy = cy - p.y * SCALE;

    const grad = ctx.createRadialGradient(sx, sy, 0, sx, 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(sx, sy, 10, 0, Math.PI * 2); ctx.fill();

    fillCircle(ctx, sx, sy, 3, "#ffd");
    ctx.fillStyle = "#887";
    ctx.font = "10px monospace";
    ctx.textAlign = "left";
    ctx.fillText(star.name, sx + 6, sy + 3);
  }

  ctx.restore();

  // Info
  ctx.fillStyle = "#666";
  ctx.font = "11px monospace";
  ctx.textAlign = "left";
  ctx.fillText(`Mollweide  center lon: ${centerLon.toFixed(0)}°`, 14, H - 12);
  ctx.textAlign = "right";
  ctx.fillText("drag to rotate", W - 14, H - 12);
}

canvas.addEventListener("mousedown", (e) => {
  dragging = true; lastX = e.clientX;
  canvas.style.cursor = "grabbing";
});
canvas.addEventListener("mousemove", (e) => {
  if (!dragging) return;
  const dx = e.clientX - lastX;
  centerLon -= dx * 0.5;
  lastX = e.clientX;
  draw();
});
canvas.addEventListener("mouseup", () => { dragging = false; canvas.style.cursor = "grab"; });
canvas.addEventListener("mouseleave", () => { dragging = false; canvas.style.cursor = "grab"; });

canvas.addEventListener("touchstart", (e) => {
  e.preventDefault();
  const t = e.touches[0];
  dragging = true; lastX = t.clientX;
});
canvas.addEventListener("touchmove", (e) => {
  e.preventDefault();
  if (!dragging) return;
  const t = e.touches[0];
  const dx = t.clientX - lastX;
  centerLon -= dx * 0.5;
  lastX = t.clientX;
  draw();
});
canvas.addEventListener("touchend", () => { dragging = false; });

draw();
export default canvas;

Newton-Raphson: Solving the Unsolvable

The Mollweide projection requires solving 2θ + sin(2θ) = π sin(φ) — an equation with no algebraic solution. Newton-Raphson is an iterative method for finding roots of equations like this: start with a guess, compute where the tangent line crosses zero, move there, and repeat.

Given a function f(θ) where we want f(θ) = 0:

θn+1 = θn − f(θn) / f'(θn)

For Mollweide: f(θ) = 2θ + sin(2θ) − π sin(φ) and f'(θ) = 2 + 2cos(2θ).

The visualization below shows Newton-Raphson converging to a solution. The blue curve is f(θ), the green dot is the root, and the red tangent lines show each iteration stepping closer. Most latitudes converge in 2-4 iterations.

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

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

const controls = document.createElement("div");
controls.style.cssText = "display:flex;gap:12px;align-items:center;font:13px monospace;margin-top:8px;max-width:520px";

const latSlider = document.createElement("input");
latSlider.type = "range"; latSlider.min = "5"; latSlider.max = "85"; latSlider.value = "50";
latSlider.style.cssText = "flex:1;min-width:120px";

const latLabel = document.createElement("span");
latLabel.style.color = "#aaa";

controls.append("Latitude: ", latSlider, latLabel);

const DEG = Math.PI / 180;

function draw() {
  const lat = parseInt(latSlider.value);
  latLabel.textContent = `${lat}°`;

  const φ = lat * DEG;
  const target = Math.PI * Math.sin(φ);

  // f(θ) = 2θ + sin(2θ) - target
  const f = (θ: number) => 2 * θ + Math.sin(2 * θ) - target;
  const fPrime = (θ: number) => 2 + 2 * Math.cos(2 * θ);

  // Run Newton-Raphson, recording steps
  const steps: { θ: number; fθ: number }[] = [];
  let θ = φ;
  steps.push({ θ, fθ: f(θ) });
  for (let i = 0; i < 8; i++) {
    const fp = fPrime(θ);
    if (Math.abs(fp) < 1e-12) break;
    const delta = f(θ) / fp;
    θ -= delta;
    steps.push({ θ, fθ: f(θ) });
    if (Math.abs(delta) < 1e-10) break;
  }
  const root = θ;

  // Plot range
  const tMin = -0.2, tMax = Math.PI / 2 + 0.3;
  const fMin = -2, fMax = 4;

  const plotLeft = 60, plotRight = W - 20;
  const plotTop = 20, plotBottom = H - 50;
  const plotW = plotRight - plotLeft;
  const plotH = plotBottom - plotTop;

  const toSx = (t: number) => plotLeft + (t - tMin) / (tMax - tMin) * plotW;
  const toSy = (fv: number) => plotBottom - (fv - fMin) / (fMax - fMin) * plotH;

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

  // Axes
  ctx.strokeStyle = "#2a2a3a";
  ctx.lineWidth = 1;
  // x-axis (f=0)
  const zeroY = toSy(0);
  ctx.beginPath(); ctx.moveTo(plotLeft, zeroY); ctx.lineTo(plotRight, zeroY); ctx.stroke();
  // y-axis (θ=0)
  const zeroX = toSx(0);
  if (zeroX >= plotLeft && zeroX <= plotRight) {
    ctx.beginPath(); ctx.moveTo(zeroX, plotTop); ctx.lineTo(zeroX, plotBottom); ctx.stroke();
  }

  // Labels
  ctx.fillStyle = "#555";
  ctx.font = "10px monospace";
  ctx.textAlign = "center";
  ctx.fillText("θ", plotRight + 10, zeroY + 4);
  ctx.textAlign = "right";
  ctx.fillText("f(θ)", plotLeft - 8, plotTop + 4);

  // Draw f(θ) curve
  ctx.strokeStyle = "#4a7aaa";
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let px = plotLeft; px <= plotRight; px++) {
    const t = tMin + (px - plotLeft) / plotW * (tMax - tMin);
    const fv = f(t);
    const sy = toSy(fv);
    if (px === plotLeft) ctx.moveTo(px, sy);
    else ctx.lineTo(px, sy);
  }
  ctx.stroke();

  // Draw Newton-Raphson steps
  for (let i = 0; i < steps.length - 1; i++) {
    const { θ: θi, fθ } = steps[i];
    const sx = toSx(θi);
    const sy = toSy(fθ);

    // Point on curve
    fillCircle(ctx, sx, sy, 4, "#c06040");

    // Tangent line: passes through (θi, fθ) with slope fPrime(θi)
    const slope = fPrime(θi);
    const nextθ = θi - fθ / slope;

    // Draw tangent from point down to x-axis
    ctx.strokeStyle = "rgba(200,100,80,0.6)";
    ctx.lineWidth = 1.5;
    ctx.setLineDash([4, 3]);
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(toSx(nextθ), toSy(0));
    ctx.stroke();
    ctx.setLineDash([]);

    // Vertical line from x-axis to next point on curve (if not last)
    if (i < steps.length - 2) {
      ctx.strokeStyle = "rgba(200,100,80,0.3)";
      ctx.lineWidth = 1;
      ctx.setLineDash([2, 3]);
      ctx.beginPath();
      ctx.moveTo(toSx(nextθ), toSy(0));
      ctx.lineTo(toSx(nextθ), toSy(f(nextθ)));
      ctx.stroke();
      ctx.setLineDash([]);
    }

    // Iteration label
    ctx.fillStyle = "#c06040";
    ctx.font = "9px monospace";
    ctx.textAlign = "center";
    ctx.fillText(${i}`, sx, sy - 8);
  }

  // Root marker
  const rootSx = toSx(root);
  fillCircle(ctx, rootSx, toSy(0), 6, "#4aba4a");
  ctx.fillStyle = "#4aba4a";
  ctx.font = "bold 11px monospace";
  ctx.textAlign = "center";
  ctx.fillText(`θ = ${root.toFixed(4)}`, rootSx, toSy(0) + 18);

  // Info
  ctx.fillStyle = "#666";
  ctx.font = "11px monospace";
  ctx.textAlign = "left";
  ctx.fillText(`φ = ${lat}°  →  ${steps.length - 1} iterations  →  θ = ${root.toFixed(6)}`, 14, H - 12);
}

latSlider.addEventListener("input", draw);
draw();

container.append(canvas, controls);
export default container;

The Four Projections

We now have four projections: two azimuthal (stereographic, orthographic) and two that show the entire sky (Mercator, Mollweide). Let us collect them in a registry and see them side by side.

import type { ProjectionFn } from './03-projections.org?name=projection-types';
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';

/** All available projections, keyed by name */
export const projections: Record<string, ProjectionFn> = {
  stereographic,
  orthographic,
  mercator,
  mollweide,
};

The same stars rendered in all four projections simultaneously. Drag to rotate/pan — all four views are synced to the same center longitude.

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, strokeCircle } from './01-coordinates.org?name=draw-helpers';

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 = 30, centerLon = 90;
let dragging = false, lastX = 0, lastY = 0;

const stars = [
  { ra: 101.3, dec: -16.7, name: "Sirius" },
  { ra: 79.2, dec: 46.0, name: "Capella" },
  { ra: 88.8, dec: 7.4, name: "Betelgeuse" },
  { ra: 78.6, dec: -8.2, name: "Rigel" },
  { ra: 114.8, dec: 5.2, name: "Procyon" },
  { ra: 279.2, dec: 38.8, name: "Vega" },
  { ra: 297.7, dec: 8.9, name: "Altair" },
  { ra: 310.4, dec: 45.3, name: "Deneb" },
  { ra: 37.95, dec: 89.26, name: "Polaris" },
  { ra: 213.9, dec: 19.2, name: "Arcturus" },
  { ra: 201.3, dec: -11.2, name: "Spica" },
  { ra: 247.4, dec: -26.4, name: "Antares" },
];

interface PanelOpts {
  proj: ProjectionFn;
  label: string;
  ox: number;
  oy: number;
  scale: number;
  clipMode: "circle" | "rect" | "ellipse";
  clipW: number;
  clipH: number;
  useCenter: boolean; // whether to use centerLat
}

const panels: PanelOpts[] = [
  { proj: stereographic, label: "Stereographic", ox: W/4, oy: H/4 + 10, scale: 75, clipMode: "circle", clipW: 160, clipH: 160, useCenter: true },
  { proj: orthographic, label: "Orthographic", ox: 3*W/4, oy: H/4 + 10, scale: 160, clipMode: "circle", clipW: 160, clipH: 160, useCenter: true },
  { proj: mercator, label: "Mercator", ox: W/4, oy: 3*H/4 - 30, scale: 70, clipMode: "rect", clipW: 200, clipH: 140, useCenter: false },
  { proj: mollweide, label: "Mollweide", ox: 3*W/4, oy: 3*H/4 - 30, scale: 70, clipMode: "ellipse", clipW: 200, clipH: 100, useCenter: false },
];

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

  for (const panel of panels) {
    const { proj, label, ox, oy, scale, clipMode, clipW, clipH, useCenter } = panel;
    const lat0 = useCenter ? centerLat : 0;
    const lon0 = centerLon;

    ctx.save();
    ctx.beginPath();
    if (clipMode === "circle") {
      ctx.arc(ox, oy, clipW, 0, Math.PI * 2);
    } else if (clipMode === "ellipse") {
      ctx.ellipse(ox, oy, clipW, clipH, 0, 0, Math.PI * 2);
    } else {
      ctx.rect(ox - clipW, oy - clipH, clipW * 2, clipH * 2);
    }
    ctx.clip();

    // Background
    ctx.fillStyle = "#0a0c18";
    ctx.fill();

    // Grid
    for (let lat = -80; lat <= 80; lat += 20) {
      ctx.strokeStyle = lat === 0 ? "#3a3a5a" : "#1a1e28";
      ctx.lineWidth = lat === 0 ? 1 : 0.5;
      ctx.beginPath();
      let started = false;
      for (let lon = -180; lon <= 180; lon += 2) {
        const p = proj(lat, lon, lat0, lon0);
        if (!p.visible) { started = false; continue; }
        const sx = ox + p.x * scale, sy = oy - p.y * 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 ? "#3a3a5a" : "#1a1e28";
      ctx.lineWidth = lon === 0 ? 1 : 0.5;
      ctx.beginPath();
      let started = false;
      for (let lat = -90; lat <= 90; lat += 2) {
        const p = proj(lat, lon, lat0, lon0);
        if (!p.visible) { started = false; continue; }
        const sx = ox + p.x * scale, sy = oy - p.y * scale;
        if (!started) { ctx.moveTo(sx, sy); started = true; }
        else ctx.lineTo(sx, sy);
      }
      ctx.stroke();
    }

    // Stars
    for (const star of stars) {
      const p = proj(star.dec, star.ra, lat0, lon0);
      if (!p.visible) continue;
      const sx = ox + p.x * scale, sy = oy - p.y * scale;

      fillCircle(ctx, sx, sy, 2.5, "#ffd");
      ctx.fillStyle = "#776";
      ctx.font = "9px monospace";
      ctx.textAlign = "left";
      ctx.fillText(star.name, sx + 4, sy + 3);
    }

    ctx.restore();

    // Border
    ctx.strokeStyle = "#4a4a5a";
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    if (clipMode === "circle") {
      ctx.arc(ox, oy, clipW, 0, Math.PI * 2);
    } else if (clipMode === "ellipse") {
      ctx.ellipse(ox, oy, clipW, clipH, 0, 0, Math.PI * 2);
    } else {
      ctx.rect(ox - clipW, oy - clipH, clipW * 2, clipH * 2);
    }
    ctx.stroke();

    // Label
    ctx.fillStyle = "#5a6a8a";
    ctx.font = "bold 11px monospace";
    ctx.textAlign = "center";
    ctx.fillText(label, ox, oy - clipH - 8);
  }

  ctx.fillStyle = "#555";
  ctx.font = "10px monospace";
  ctx.textAlign = "center";
  ctx.fillText(`center: ${centerLat.toFixed(0)}°, ${centerLon.toFixed(0)}°  —  drag to rotate`, 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;
  const dx = e.clientX - lastX, dy = e.clientY - lastY;
  centerLon -= dx * 0.4;
  centerLat = Math.max(-89, Math.min(89, centerLat + dy * 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("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.4;
  centerLat = Math.max(-89, Math.min(89, centerLat + dy * 0.4));
  lastX = t.clientX; lastY = t.clientY;
  draw();
});
canvas.addEventListener("touchend", () => { dragging = false; });

draw();
export default canvas;
PropertyStereographicOrthographicMercatorMollweide
TypeAzimuthalAzimuthalCylindricalPseudo-cylindrical
ConformalYesNoYesNo
Equal-areaNoNoNoYes
Shows whole skyNoNoYes (except poles)Yes
DistortionSize increases from centerCompression at edgesArea increases at polesShape distorts at edges
Best forStar charts, astrolabesGlobe illustrationsNavigation, directionSurveys, density maps

Choosing a Projection

There is no universally correct projection. The choice depends on the task:

  • Identifying constellations: Stereographic — preserves the shapes you recognize.
  • Illustrating the sky as a globe: Orthographic — matches intuitive expectations.
  • Plotting star positions for navigation: Mercator — constant compass bearings are straight lines.
  • All-sky surveys and density maps: Mollweide — areas are truthful, essential for statistical analysis.
  • Quick reference charts: Equirectangular (not implemented here) — simplest math, easy to index.

Many real star charts use projections not covered here: gnomonic for great-circle navigation, Aitoff or Hammer for a compromise between shape and area, HEALPix for computational surveys. The framework we have built — the ProjectionFn interface and the grid renderer — makes it straightforward to add any of these.

The younger Tuareg sits with the merchant and the elder, looking at the four ways they have found to flatten a sphere. The astrolabe's stereographic circles, the globe-like orthographic view, the merchant's rectangular Mercator chart, and the elder's equal-area oval.

Each lies about something to tell the truth about something else.

We have the coordinates, the elder says. We have the time. We have the projections. But we have been working with a dozen stars. The real sky has thousands. For the next lesson, we need a catalog.

Next

In the next article we load a real star catalog and render thousands of stars across these projections — turning our mathematical framework into a working star chart.


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.