4
Moon Phases Simulator: Why the Moon Changes Shape
drag the Moon around its orbit; tap 'labels' to toggle waxing/waning
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.