4

Moon Phases Simulator: Why the Moon Changes Shape

drag the Moon around its orbit; tap 'labels' to toggle waxing/waning

An interactive moon phases simulator: the top panel shows the Sun–Earth–Moon geometry from above, with the Moon's sunlit half always facing the Sun, while the bottom panel renders the Moon exactly as you'd see it from Earth that night. Phases are pure viewing geometry — the illuminated fraction is where is the Moon's elongation — and NOT Earth's shadow, the most common misconception (that's a lunar eclipse). Drag the Moon around its orbit and watch the phase, illumination percentage, and day of the 29.5-day cycle update live; it auto-orbits when you let go.

idle
152 lines · vanilla
view source
// Moon phases: viewing geometry, not Earth's shadow. Top panel = top-down
// Sun/Earth/Moon geometry; bottom panel = the Moon as seen from Earth.
const SYNODIC = 29.53;
const BTN_W = 96, BTN_H = 44, PAD = 12;
let W = 0, H = 0;
let theta;        // elongation from Sun direction, 0 = new moon
let idleT;        // seconds since last drag
let showLabels;
let stars;
let top, bot;     // panels

function layout() {
  const split = Math.round(H * 0.52);
  top = { x: 0, y: 0, w: W, h: split };
  bot = { x: 0, y: split, w: W, h: H - split };
}

function init({ width, height }) {
  W = width; H = height;
  layout();
  theta = 2.2;          // waxing gibbous: pretty first frame
  idleT = 99;
  showLabels = true;
  stars = new Float32Array(150 * 3);
  for (let i = 0; i < 150; i++) {
    stars[i * 3] = Math.random();
    stars[i * 3 + 1] = Math.random();
    stars[i * 3 + 2] = 0.2 + Math.random() * 0.8;
  }
}

function phaseName(d) {
  if (d < 1 || d > 28.5) return "New Moon";
  if (d < 6.4) return "Waxing Crescent";
  if (d < 8.4) return "First Quarter";
  if (d < 13.8) return "Waxing Gibbous";
  if (d < 15.8) return "Full Moon";
  if (d < 21.1) return "Waning Gibbous";
  if (d < 23.1) return "Last Quarter";
  return "Waning Crescent";
}

function btnRect() { return { x: W - BTN_W - PAD, y: H - BTN_H - PAD, w: BTN_W, h: BTN_H }; }
function inRect(x, y, r) { return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; }

// Draw a moon disc showing illuminated fraction; litRight = waxing.
function drawPhaseDisc(ctx, cx, cy, R, k, litRight) {
  // k = cos(theta): k>0 crescent, k<0 gibbous. Terminator ellipse rx = R|k|.
  ctx.save();
  ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.clip();
  ctx.fillStyle = "#1a1d27";                       // dark side
  ctx.fillRect(cx - R, cy - R, 2 * R, 2 * R);
  const lit = ctx.createRadialGradient(cx - R * 0.2, cy - R * 0.3, R * 0.1, cx, cy, R * 1.2);
  lit.addColorStop(0, "#fdf6e0"); lit.addColorStop(1, "#b8ac8d");
  ctx.fillStyle = lit;
  ctx.beginPath();                                 // lit semicircle
  ctx.arc(cx, cy, R, -Math.PI / 2, Math.PI / 2, !litRight);
  ctx.fill();
  ctx.beginPath();                                 // terminator ellipse
  ctx.ellipse(cx, cy, Math.max(0.01, R * Math.abs(k)), R, 0, 0, Math.PI * 2);
  ctx.fillStyle = k > 0 ? "#1a1d27" : lit;         // crescent eats, gibbous adds
  ctx.fill();
  // a few craters on the lit side for texture
  ctx.fillStyle = "rgba(0,0,0,0.12)";
  const s = litRight ? 1 : -1;
  ctx.beginPath(); ctx.arc(cx + s * R * 0.45, cy - R * 0.3, R * 0.13, 0, Math.PI * 2); ctx.fill();
  ctx.beginPath(); ctx.arc(cx + s * R * 0.25, cy + R * 0.35, R * 0.09, 0, Math.PI * 2); ctx.fill();
  ctx.beginPath(); ctx.arc(cx + s * R * 0.6, cy + R * 0.1, R * 0.07, 0, Math.PI * 2); ctx.fill();
  ctx.restore();
  ctx.strokeStyle = "rgba(200,210,240,0.25)";
  ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; layout(); }
  const ex = top.x + top.w * 0.56, ey = top.y + top.h * 0.52;
  const orbR = Math.min(top.w * 0.30, top.h * 0.36);
  const btn = btnRect();

  for (const c of input.consumeClicks()) {
    if (inRect(c.x, c.y, btn)) showLabels = !showLabels;
  }
  if (input.mouseDown && !inRect(input.mouseX, input.mouseY, btn) && input.mouseY < top.y + top.h) {
    theta = Math.atan2(input.mouseY - ey, input.mouseX - ex) - Math.PI;
    idleT = 0;
  } else idleT += dt;
  if (idleT > 1.5) theta += dt * (2 * Math.PI / 30);   // auto-orbit, ~30s cycle
  theta = ((theta % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);

  const day = theta / (2 * Math.PI) * SYNODIC;
  const k = Math.cos(theta);
  const illum = (1 - k) / 2;
  const waxing = theta < Math.PI;

  // ---- background + stars ----
  ctx.fillStyle = "#05070d"; ctx.fillRect(0, 0, W, H);
  for (let i = 0; i < 150; i++) {
    ctx.fillStyle = `rgba(220,230,255,${(0.2 + stars[i * 3 + 2] * 0.5).toFixed(2)})`;
    ctx.fillRect(stars[i * 3] * W, stars[i * 3 + 1] * H, 1, 1);
  }

  // ---- top panel: geometry ----
  // Sun off-left with rays
  const sunR = Math.min(top.h * 0.35, 70);
  const sg = ctx.createRadialGradient(top.x - sunR * 0.3, ey, sunR * 0.2, top.x - sunR * 0.3, ey, sunR * 1.6);
  sg.addColorStop(0, "#fff3b0"); sg.addColorStop(0.5, "#ffb627"); sg.addColorStop(1, "rgba(255,140,30,0)");
  ctx.fillStyle = sg;
  ctx.beginPath(); ctx.arc(top.x - sunR * 0.3, ey, sunR * 1.6, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = "rgba(255,200,90,0.22)"; ctx.lineWidth = 1;
  for (let i = -2; i <= 2; i++) {
    const ry = ey + i * orbR * 0.45;
    ctx.beginPath(); ctx.moveTo(top.x + sunR, ry); ctx.lineTo(ex + orbR * 1.25, ry); ctx.stroke();
  }
  // orbit ring + Earth
  ctx.strokeStyle = "rgba(140,170,220,0.25)";
  ctx.beginPath(); ctx.arc(ex, ey, orbR, 0, Math.PI * 2); ctx.stroke();
  const eg = ctx.createRadialGradient(ex - 5, ey - 5, 2, ex, ey, 14);
  eg.addColorStop(0, "#7fc4ff"); eg.addColorStop(0.6, "#2b6fd4"); eg.addColorStop(1, "#0a2a66");
  ctx.fillStyle = eg;
  ctx.beginPath(); ctx.arc(ex, ey, Math.min(14, orbR * 0.18), 0, Math.PI * 2); ctx.fill();
  // Moon on orbit: lit half ALWAYS faces the Sun (left)
  const phi = Math.PI + theta;
  const mx = ex + Math.cos(phi) * orbR, my = ey + Math.sin(phi) * orbR;
  const mr = Math.max(7, orbR * 0.11);
  ctx.fillStyle = "#23262f";
  ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = "#f2ead0";
  ctx.beginPath(); ctx.arc(mx, my, mr, Math.PI / 2, 3 * Math.PI / 2); ctx.fill(); // left = sunlit
  ctx.strokeStyle = "rgba(255,255,255,0.35)";
  ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.stroke();
  // sightline Earth -> Moon
  ctx.strokeStyle = "rgba(160,200,255,0.20)"; ctx.setLineDash([3, 4]);
  ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(mx, my); ctx.stroke(); ctx.setLineDash([]);
  if (showLabels) {
    ctx.font = "bold 12px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
    ctx.fillStyle = "rgba(140,220,160,0.8)";
    ctx.fillText("WAXING ↑", ex, ey - orbR - 12);
    ctx.fillStyle = "rgba(230,160,120,0.8)";
    ctx.fillText("WANING ↓", ex, ey + orbR + 12);
    ctx.fillStyle = "rgba(255,210,120,0.75)";
    ctx.fillText("sunlight →", top.x + sunR + 46, ey - orbR * 0.45 - 10);
  }
  // ---- bottom panel: view from Earth ----
  ctx.strokeStyle = "rgba(140,170,220,0.18)";
  ctx.beginPath(); ctx.moveTo(0, bot.y + 0.5); ctx.lineTo(W, bot.y + 0.5); ctx.stroke();
  const pr = Math.min(bot.h * 0.34, bot.w * 0.2);
  const pcx = bot.x + bot.w / 2, pcy = bot.y + bot.h * 0.52;
  const glow = ctx.createRadialGradient(pcx, pcy, pr, pcx, pcy, pr * 1.8);
  glow.addColorStop(0, `rgba(240,235,210,${(0.12 * illum).toFixed(3)})`); glow.addColorStop(1, "rgba(0,0,0,0)");
  ctx.fillStyle = glow;
  ctx.beginPath(); ctx.arc(pcx, pcy, pr * 1.8, 0, Math.PI * 2); ctx.fill();
  drawPhaseDisc(ctx, pcx, pcy, pr, k, waxing);
  ctx.fillStyle = "rgba(200,215,245,0.6)"; ctx.font = "11px system-ui, sans-serif";
  ctx.textAlign = "center"; ctx.textBaseline = "alphabetic";
  ctx.fillText("the Moon as seen from Earth tonight", pcx, bot.y + 16);

  // ---- HUD ----
  ctx.textAlign = "left";
  ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(PAD, PAD, 196, 64);
  ctx.fillStyle = "#ffd98a"; ctx.font = "bold 13px monospace";
  ctx.fillText(phaseName(day), PAD + 10, PAD + 19);
  ctx.fillStyle = "#dfe7ff"; ctx.font = "12px monospace";
  ctx.fillText(`illuminated ${(illum * 100).toFixed(0)}%`, PAD + 10, PAD + 38);
  ctx.fillText(`day ${day.toFixed(1)} / ${SYNODIC}`, PAD + 10, PAD + 55);
  ctx.textAlign = "right"; ctx.fillStyle = "rgba(180,200,240,0.55)"; ctx.font = "11px system-ui, sans-serif";
  ctx.fillText("drag the Moon around its orbit", W - PAD, PAD + 12);
  ctx.textAlign = "left";
  // labels toggle button
  ctx.fillStyle = showLabels ? "rgba(80,120,200,0.8)" : "rgba(0,0,0,0.6)";
  ctx.fillRect(btn.x, btn.y, btn.w, btn.h);
  ctx.strokeStyle = "rgba(255,255,255,0.4)"; ctx.strokeRect(btn.x + 0.5, btn.y + 0.5, btn.w - 1, btn.h - 1);
  ctx.fillStyle = "#fff"; ctx.font = "bold 13px system-ui, sans-serif";
  ctx.textAlign = "center"; ctx.textBaseline = "middle";
  ctx.fillText("labels", btn.x + btn.w / 2, btn.y + btn.h / 2);
  ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
}

Comments (0)

Log in to comment.