"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.
🌐 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"
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;
}
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);
});
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;
// ...
}
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)
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);
});
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;
| 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:
- A half-circle outer arc (the lit edge of the moon)
- 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(" ");
}
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
});
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
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);
}
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);
});
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;
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), "下弦の月");
});
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)