DEV Community

SEN LLC
SEN LLC

Posted on

Computing Moon Phase in 150 Lines of JavaScript — Julian Date, Synodic Month, and SVG Terminator

"Wait, what phase is the moon in tonight?" — that question deserves a 150-line answer, not a Google search. The math is small: convert the date to Julian Date, divide the days since a reference new moon by the synodic month (29.530588853 days), take the fractional part. The interesting bit is drawing it: the moon's terminator (the line between lit and dark) is an elliptical arc when projected to a plane, which means the whole moon glyph can be expressed as two SVG arc commands. Here's how.

moon-phase UI: large waxing crescent SVG rendered top-left. To the right, info table with phase name

🌐 Demo: https://sen.ltd/portfolio/moon-phase/
📦 GitHub: https://github.com/sen-ltd/moon-phase

Architecture: pure module + DOM glue

The whole pure-math layer is one file:

import { moonPhase, terminatorPath, nextFullMoon } from "./phase.js";

const info = moonPhase(new Date());
// → { phase: 0.1141, age: 3.4, illumination: 0.123, name: "三日月", waxing: true }

const svgPath = terminatorPath(info.phase, 100);
// → "M 0 -100 A 100 100 0 0 1 0 100 A 80.9 100 0 0 0 0 -100 Z"
Enter fullscreen mode Exit fullscreen mode

No DOM access in phase.js, which means node --test can run every test without jsdom. The UI script just imports the pure functions and writes the path string into an SVG element.

Julian Date: the astronomer's serial day number

Astronomical math hates the Gregorian calendar (variable month lengths, leap years, leap seconds…). Astronomers count time by Julian Date (JD) instead: a continuous count of days starting at noon UT on January 1, 4713 BC. One day = 1.0, time-of-day is the fractional part.

Convert Unix milliseconds to JD with one constant:

export const UNIX_EPOCH_JD = 2440587.5;       // JD of 1970-01-01 00:00 UTC
export const MS_PER_DAY = 86400000;

export function dateToJulian(date) {
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
  return date.getTime() / MS_PER_DAY + UNIX_EPOCH_JD;
}
Enter fullscreen mode Exit fullscreen mode

Why the .5? Julian Days start at noon, not midnight (so that astronomers' single observing nights don't cross a day boundary). Midnight UT is half a day past JD noon → +0.5.

Pin it:

test("dateToJulian matches the unix epoch as JD 2440587.5", () => {
  assert.equal(dateToJulian(new Date("1970-01-01T00:00:00Z")), 2440587.5);
});
Enter fullscreen mode Exit fullscreen mode

Phase = (JD − reference new moon) / synodic month mod 1

The synodic month — new moon to new moon — averages 29.530588853 days.

For a reference new moon, the convention is JD 2451550.1 (2000-01-06 14:24 UTC). NASA's ephemeris confirms a new moon at that instant.

export const SYNODIC_MONTH = 29.530588853;
export const REFERENCE_NEW_MOON_JD = 2451550.1;

export function moonPhase(date) {
  const jd = dateToJulian(date);
  const daysSinceRef = jd - REFERENCE_NEW_MOON_JD;
  let phase = (daysSinceRef / SYNODIC_MONTH) % 1;
  if (phase < 0) phase += 1;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

phase ∈ [0, 1) where 0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter.

The negative-modulo gotcha

JavaScript's % is truncated modulo, not mathematical modulo. If the dividend is negative, the result is negative:

-3 % 10   // → -3 in JS  (Python would say 7)
Enter fullscreen mode Exit fullscreen mode

For dates before the reference new moon (early 2000), daysSinceRef is negative and so is the phase. The if (phase < 0) phase += 1 normalises it. Test the round-trip both forward and backward:

test("moonPhase at the reference new moon JD gives phase ≈ 0", () => {
  const d = julianToDate(REFERENCE_NEW_MOON_JD);
  const info = moonPhase(d);
  assert.ok(info.phase < 0.001 || info.phase > 0.999);
});

test("moonPhase one synodic month after the reference is again ~new", () => {
  const d = julianToDate(REFERENCE_NEW_MOON_JD + SYNODIC_MONTH);
  const info = moonPhase(d);
  assert.ok(info.phase < 0.001 || info.phase > 0.999);
});
Enter fullscreen mode Exit fullscreen mode

Illumination = (1 − cos(2π × phase)) / 2

The lit fraction of the moon as seen from Earth is a linear transform of the cosine of the phase angle:

const illumination = (1 - Math.cos(2 * Math.PI * phase)) / 2;
Enter fullscreen mode Exit fullscreen mode
phase cos(2π × phase) illumination
0 (new) 1 0
0.25 (first quarter) 0 0.5
0.5 (full) -1 1
0.75 (last quarter) 0 0.5

First quarter and last quarter are indistinguishable from illumination alone. The waxing flag (phase < 0.5) is the other axis.

The terminator is an elliptical arc — two SVG arcs draw the entire lit area

This is the bit I found cute. The terminator — the boundary between the lit and dark sides of the moon — is a great circle on the lunar surface. Projected onto the plane of view, it becomes an ellipse. So the lit portion of the moon disc can be decomposed as:

  1. A half-circle outer arc (the lit edge of the moon)
  2. A half-ellipse inner arc (the terminator)

With semi-minor axis r × |cos(2π × phase)|:

export function terminatorPath(phase, radius) {
  if (phase < 0.001 || phase > 0.999) return "";              // new moon: nothing lit
  if (Math.abs(phase - 0.5) < 0.001) return circlePath(radius); // full moon: whole disc

  const cosPhase = Math.cos(2 * Math.PI * phase);
  const minor = Math.abs(cosPhase) * radius;
  const r = radius;
  const waxing = phase < 0.5;

  const outerSweep = waxing ? 1 : 0;
  const innerSweep = cosPhase < 0 ? 1 : 0;

  return [
    `M 0 ${-r}`,
    `A ${r} ${r} 0 0 ${outerSweep} 0 ${r}`,
    `A ${minor} ${r} 0 0 ${innerSweep} 0 ${-r}`,
    "Z",
  ].join(" ");
}
Enter fullscreen mode Exit fullscreen mode

SVG arc sweep flag, demystified

A rx ry x-axis-rotation large-arc-flag sweep-flag x y. The sweep flag is the gotcha:

  • 0 — arc traced counter-clockwise from start to end
  • 1 — arc traced clockwise from start to end

For a half-circle from (0, -r) (top) to (0, r) (bottom):

  • waxing (lit on the right) → clockwise → sweep flag 1
  • waning (lit on the left) → counter-clockwise → sweep flag 0

Why cosPhase's sign tells you the inner sweep

The inner half-ellipse needs to either bulge into the lit side (crescent — terminator carves the lit area down to a sliver) or bulge into the dark side (gibbous — terminator pushes the lit area beyond half the disc).

cos(2π × phase) flips sign at exactly the right moments:

  • phase ∈ (0, 0.25) — waxing crescent — cos > 0 — ellipse on the lit side
  • phase ∈ (0.25, 0.5) — waxing gibbous — cos < 0 — ellipse bulges into dark
  • phase ∈ (0.5, 0.75) — waning gibbous — cos < 0 — ellipse bulges into dark
  • phase ∈ (0.75, 1) — waning crescent — cos > 0 — ellipse on the lit side

So innerSweep = cosPhase < 0 ? 1 : 0 covers all four cases in one line. Pin it:

test("terminatorPath gibbous bulges into dark side (sweep flag 1 on inner arc)", () => {
  const path = terminatorPath(0.4, 100);   // waxing gibbous
  const arcs = path.match(/A [^A]+/g);
  assert.equal(arcs.length, 2);
  assert.ok(arcs[1].includes("0 0 1 0"));   // inner arc sweep flag = 1
});
Enter fullscreen mode Exit fullscreen mode

Next new/full moon: closed-form, no binary search

"When's the next full moon?" — the naive solution loops day-by-day looking for |phase - 0.5| < ε. But phase is a linear function of JD, so just invert it:

phase(jd) = ((jd - REF) / S) mod 1

jd at the next phase = target:
  REF + (target + k) × S   for the smallest integer k where this > startJd
Enter fullscreen mode Exit fullscreen mode
export function nextPhase(date, target) {
  const startJd = dateToJulian(date);
  const offset = (startJd - REFERENCE_NEW_MOON_JD) / SYNODIC_MONTH;
  const k = Math.ceil(offset - target);
  const jd = REFERENCE_NEW_MOON_JD + (target + k) * SYNODIC_MONTH;
  return julianToDate(jd);
}
Enter fullscreen mode Exit fullscreen mode

offset is "how many synodic months past the reference, including fractional position". Math.ceil(offset - target) picks the smallest integer k such that target + k > offset, i.e., the next time the phase wraps around to target.

Verify by checking that consecutive new moons are exactly one synodic month apart:

test("consecutive new moons are ~SYNODIC_MONTH days apart", () => {
  const first = nextNewMoon(new Date("2024-01-01T00:00:00Z"));
  const second = nextNewMoon(new Date(first.getTime() + 86400000));  // skip past first
  const diff = (second - first) / 86400000;
  assert.ok(Math.abs(diff - SYNODIC_MONTH) < 0.01);
});
Enter fullscreen mode Exit fullscreen mode

Phase-name binning with a 1/16 offset

Mapping phase ∈ [0, 1) to one of 8 phase names (new, waxing crescent, first quarter, …) needs each canonical phase to land in the centre of its bin, not at a bin edge. Offset by 1/16:

const idx = Math.floor((p + 1 / 16) * 8) % 8;
Enter fullscreen mode Exit fullscreen mode

phase = 0 (new) → (0 + 1/16) × 8 = 0.5 → floor → 0. phase = 0.25 (first quarter) → (0.25 + 1/16) × 8 = 2.5 → 2. Etc. Bin boundaries fall at 1/16, 3/16, 5/16, ..., equidistant from the canonical phases.

test("phaseName maps canonical phases to expected labels", () => {
  assert.equal(phaseName(0),    "新月");          // new
  assert.equal(phaseName(0.25), "上弦の月");     // first quarter
  assert.equal(phaseName(0.5),  "満月");          // full
  assert.equal(phaseName(0.75), "下弦の月");     // last quarter
});

test("phaseName handles wrap-around", () => {
  assert.equal(phaseName(1.0),   "新月");
  assert.equal(phaseName(-0.25), "下弦の月");
});
Enter fullscreen mode Exit fullscreen mode

On accuracy — this is mean-phase, not true-phase

SYNODIC_MONTH = 29.530588853 is a mean value. The actual lunar month varies by about ±7 hours due to Earth's elliptical orbit (faster at perihelion) and the moon's orbital perturbations.

What that means for the code:

  • Casual use ("what phase is the moon in tonight?") — accurate enough
  • Exact new/full moon times — off by up to ±12 hours
  • Astronomical-grade work — use ELP-2000/82 or similar (hundreds of periodic terms)

This trade-off is called out in the README. For 150 lines, ±12 hours is the right amount of accuracy.

TL;DR

  • Julian Date decouples astronomy from calendar arithmetic. JD = unix_ms / 86400000 + 2440587.5.
  • Phase = ((JD - REF_NEW_MOON_JD) / SYNODIC_MONTH) mod 1. Watch JS's negative-modulo behaviour.
  • Illumination = (1 - cos(2π × phase)) / 2. Symmetric around full — track waxing/waning separately.
  • The terminator projects to an ellipse. The whole lit disc is two SVG arcs: a half-circle + a half-ellipse, with sweep flags determined by (waxing, cosPhase sign).
  • Next full/new moon has a closed-form. No bisection needed since phase is linear in JD.
  • Mean synodic month → ±12-hour accuracy for transition times. For sub-minute precision, switch to ELP-2000.

Source: https://github.com/sen-ltd/moon-phase — MIT, ~150 lines of JS + 30 unit tests, zero runtime dependencies, no build step.


🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)