← DEVLOG
Space Science2025.12.146 min read

Moon Phase — Drawing Tonight's Moon with a Single Formula

Calculating the current moon phase from the 29.53-day synodic cycle and rendering it on Canvas — no external API, pure math

canvasastronomylunar-cyclemath

The Question — Can We Calculate the Moon Without an API?

When I started building a moon phase tracker, my first instinct was to look for an API. But it turns out the moon's phase can be calculated entirely from mathematics — no external data needed.

The lunar synodic cycle is 29.53058867 days. With one known new moon reference point and today's date, you can determine the current phase.


Phase Calculation — The Synodic Cycle Formula

January 6, 2000 at 18:14 UTC is a well-documented new moon moment. The elapsed days from that reference divided by the synodic cycle gives the current phase.

const LUNAR_CYCLE = 29.53058867;

function getMoonPhase(date: Date): number {
  const KNOWN_NEW_MOON = new Date('2000-01-06T18:14:00Z');
  const days = (date.getTime() - KNOWN_NEW_MOON.getTime()) / 86400000;
  const phase = ((days % LUNAR_CYCLE) + LUNAR_CYCLE) % LUNAR_CYCLE;
  return phase / LUNAR_CYCLE; // 0~1 (0=new moon, 0.5=full moon)
}

The double-modulo pattern (% CYCLE + CYCLE) % CYCLE handles negative values, which occur when querying past dates before the reference point.


Illumination and 8-Phase Classification

The illumination percentage from a phase value uses a cosine function:

function getIllumination(phase: number): number {
  return (1 - Math.cos(phase * 2 * Math.PI)) / 2;
}

The 8 named phases are determined by range:

function getPhaseName(phase: number): PhaseName {
  if (phase < 0.0625 || phase >= 0.9375) return 'new';
  if (phase < 0.1875) return 'waxing-crescent';
  if (phase < 0.3125) return 'first-quarter';
  if (phase < 0.4375) return 'waxing-gibbous';
  if (phase < 0.5625) return 'full';
  if (phase < 0.6875) return 'waning-gibbous';
  if (phase < 0.8125) return 'last-quarter';
  return 'waning-crescent';
}

Canvas Moon Rendering — Elliptical Arc Terminator

The key to a convincing moon is rendering the day-night boundary (terminator) naturally. The approach: fill the full circle dark, then overlay the illuminated portion using a semicircle clipped with an elliptical arc.

// isWaxing = phase < 0.5: right side is bright
// localPhase = waxing ? phase : 1 - phase
// Ellipse x-radius: r * |1 - localPhase * 4|
// Direction: concave (0~0.25) → convex (0.25~0.5)

The terminator transitions from a straight line (quarter moon) to a curved line (crescent or gibbous), matching how the actual terminator appears across the lunar cycle.


Mini Moon Calendar

A monthly calendar shows all 30 days' phases as small icons. Each date's phase is pre-calculated and rendered to a 16×16 offscreen canvas.

const miniMoons = useMemo(() => {
  return Array.from({ length: daysInMonth }, (_, i) => {
    const date = new Date(year, month, i + 1);
    return getMoonPhase(date);
  });
}, [year, month]);

Today's date is highlighted with a border; the selected date gets a brightened background.


Days Until Next Phase

Counting down to the next major phase (new moon, quarter, full) from the current position:

function getDaysToNextPhase(currentPhase: number) {
  const targets = [0, 0.25, 0.5, 0.75, 1.0];
  const next = targets.find(t => t > currentPhase) ?? 1.0;
  const days = (next - currentPhase) * LUNAR_CYCLE;
  return { name: getPhaseName(next % 1), days: Math.ceil(days) };
}

Closing Thoughts

There is something elegant about the fact that the shape of the moon is determined by a single number — its position within a 29.53-day cycle. One reference point and one formula can calculate the moon's shape for thousands of years in either direction.

When tonight's moon first appeared on screen, derived purely from date arithmetic, it was a reminder that mathematics and the physical world are the same thing, just described differently.

Content related to this post

Try it yourself