6

Solar Eclipse Simulator: Umbra and Penumbra

drag near Earth to move the Moon closer/farther; +/- adjusts orbital tilt

A solar eclipse simulator that draws the Moon's umbra and penumbra cones from the true tangent lines between the Sun and Moon discs. Because the lunar orbit is tilted to the ecliptic, the shadow misses Earth most months — the HUD counts months elapsed versus eclipses scored, and a ground-view inset shows totality's corona, an annular 'ring of fire', or a partial bite when the shadow connects. Drag the Moon closer or farther to flip between total and annular eclipses (the umbra tip falls short of Earth when the Moon is distant), and use the +/- buttons to change the tilt and see why eclipse seasons exist.

idle
150 lines · vanilla
view source
// Solar eclipse geometry: umbra + penumbra cones from true disc tangents.
// The Moon's 5.1° orbital tilt makes the shadow miss Earth most months.
const BTN = 44, PAD = 12;
const MONTH_T = 5.0;           // seconds per synodic month
let W = 0, H = 0;
let phi;                       // moon orbital phase, 0 = new moon
let node;                      // ascending node longitude (drifts)
let tiltDeg;                   // adjustable inclination
let orbR;                      // moon orbit radius (drag to change)
let months, eclipses, passMax; // passMax: best eclipse type this new moon (0..3)
let stars;

function init({ width, height }) {
  W = width; H = height;
  phi = -0.35;                 // start just before a new moon that hits
  node = 0;                    // sin(phi+node)=0 at new moon -> instant payoff
  tiltDeg = 5.1;
  orbR = 0;                    // set from W in tick (responsive)
  months = 0; eclipses = 0; passMax = 0;
  stars = new Float32Array(140 * 3);
  for (let i = 0; i < 140; i++) {
    stars[i * 3] = Math.random(); stars[i * 3 + 1] = Math.random();
    stars[i * 3 + 2] = 0.2 + Math.random() * 0.8;
  }
}

function inRect(x, y, rx, ry, rw, rh) { return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh; }

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  const cy = H * 0.55;
  const sunX = W * 0.10, sunR = Math.min(W * 0.085, H * 0.22);
  const earthX = W * 0.88, earthR = Math.min(W * 0.034, H * 0.09);
  const moonR = sunR * 0.24;
  const minOrb = W * 0.08, maxOrb = W * 0.22;
  if (!orbR) orbR = W * 0.145;
  orbR = Math.max(minOrb, Math.min(maxOrb, orbR));

  // buttons (tilt +/-) bottom-right
  const pX = W - PAD - BTN, mX = pX - BTN - 8, bY = H - PAD - BTN;
  for (const c of input.consumeClicks()) {
    if (inRect(c.x, c.y, mX, bY, BTN, BTN)) tiltDeg = Math.max(0, tiltDeg - 0.5);
    else if (inRect(c.x, c.y, pX, bY, BTN, BTN)) tiltDeg = Math.min(10, tiltDeg + 0.5);
  }
  const onBtns = inRect(input.mouseX, input.mouseY, mX, bY - 6, 2 * BTN + 8 + PAD, BTN + 6);
  if (input.mouseDown && !onBtns) {
    const dx = input.mouseX - earthX, dy = input.mouseY - cy;
    orbR = Math.max(minOrb, Math.min(maxOrb, Math.hypot(dx, dy)));
  }

  // orbit: exaggerate the tilt offset so misses dominate (like reality)
  phi += dt * 2 * Math.PI / MONTH_T;
  if (phi > Math.PI) { // passage bookkeeping at full moon: commit & start fresh
    phi -= 2 * Math.PI; months++;
    if (passMax > 0) eclipses++;
    passMax = 0;
    node += 0.5;       // nodal regression, sped up
  }
  const tilt = tiltDeg * Math.PI / 180;
  const mx = earthX - Math.cos(phi) * orbR;
  const my = cy + Math.sin(tilt) * Math.sin(phi + node) * orbR * 10; // 10x exaggeration

  // shadow geometry from disc tangents (sun center S -> moon center M)
  const dxm = mx - sunX, dym = my - cy;
  const D = Math.hypot(dxm, dym);
  const ux = dxm / D, uy = dym / D, px = -uy, py = ux;
  const Lu = D * moonR / (sunR - moonR);             // umbra apex past moon
  const Lp = D * moonR / (sunR + moonR);             // penumbra apex before moon
  // does the shadow axis hit Earth's plane?
  const sAx = (earthX - mx) / ux;                    // axis distance moon->earth plane
  const yHit = my + uy * sAx;
  const ru = moonR * (1 - sAx / Lu);                 // umbra radius at Earth (neg = annular)
  const rp = moonR + sAx * (sunR + moonR) / D;       // penumbra radius at Earth
  let type = 0; // 0 none, 1 partial, 2 annular, 3 total
  if (Math.cos(phi) > 0 && sAx > 0) {
    const dy = Math.abs(yHit - cy);
    if (dy < earthR + Math.abs(ru)) type = ru > 0 ? 3 : 2;
    else if (dy < earthR + rp) type = 1;
  }
  if (type > passMax) passMax = type;

  // ---- draw ----
  ctx.fillStyle = "#0a0d18"; ctx.fillRect(0, 0, W, H);
  for (let i = 0; i < 140; i++) {
    ctx.fillStyle = `rgba(220,230,255,${(0.15 + stars[i * 3 + 2] * 0.45).toFixed(2)})`;
    ctx.fillRect(stars[i * 3] * W, stars[i * 3 + 1] * H, 1, 1);
  }
  // penumbra (diverging) then umbra (converging) cones
  const t1x = mx + px * moonR, t1y = my + py * moonR;
  const t2x = mx - px * moonR, t2y = my - py * moonR;
  const bx = mx - ux * Lp, by = my - uy * Lp;        // internal apex
  const ext = Math.max(0, (W + 20 - mx) / ux);       // extend to right edge
  ctx.fillStyle = "rgba(110,120,160,0.18)";
  ctx.beginPath();
  ctx.moveTo(t1x, t1y);
  ctx.lineTo(t1x + (t1x - bx) / Lp * ext, t1y + (t1y - by) / Lp * ext);
  ctx.lineTo(t2x + (t2x - bx) / Lp * ext, t2y + (t2y - by) / Lp * ext);
  ctx.lineTo(t2x, t2y); ctx.closePath(); ctx.fill();
  const ax = mx + ux * Lu, ay = my + uy * Lu;
  ctx.fillStyle = "rgba(5,5,12,0.9)";
  ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(t1x, t1y); ctx.lineTo(ax, ay); ctx.lineTo(t2x, t2y);
  ctx.closePath(); ctx.fill(); ctx.stroke();
  if (ru < 0) { // antumbra: faint cone past the apex (annular zone)
    ctx.fillStyle = "rgba(255,150,60,0.10)";
    ctx.beginPath(); ctx.moveTo(ax, ay);
    ctx.lineTo(t1x + (t1x - bx) / Lp * ext, t1y + (t1y - by) / Lp * ext);
    ctx.lineTo(t2x + (t2x - bx) / Lp * ext, t2y + (t2y - by) / Lp * ext);
    ctx.closePath(); ctx.fill();
  }
  // Sun
  const sg = ctx.createRadialGradient(sunX, cy, sunR * 0.2, sunX, cy, sunR * 1.5);
  sg.addColorStop(0, "#fff7c0"); sg.addColorStop(0.55, "#ffb627"); sg.addColorStop(1, "rgba(255,130,20,0)");
  ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(sunX, cy, sunR * 1.5, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = "#ffd34d"; ctx.beginPath(); ctx.arc(sunX, cy, sunR, 0, Math.PI * 2); ctx.fill();
  // Earth + nominal orbit line (dashed, with tilt wobble hinted)
  ctx.strokeStyle = "rgba(140,170,220,0.20)"; ctx.setLineDash([2, 4]);
  ctx.beginPath(); ctx.arc(earthX, cy, orbR, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
  const eg = ctx.createRadialGradient(earthX - earthR * 0.3, cy - earthR * 0.3, 1, earthX, cy, earthR);
  eg.addColorStop(0, "#8fd0ff"); eg.addColorStop(0.6, "#2b6fd4"); eg.addColorStop(1, "#08214d");
  ctx.fillStyle = eg; ctx.beginPath(); ctx.arc(earthX, cy, earthR, 0, Math.PI * 2); ctx.fill();
  // Moon
  ctx.fillStyle = "#b9b09a"; ctx.beginPath(); ctx.arc(mx, my, moonR, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = "rgba(0,0,0,0.45)"; // night side faces away from the Sun (+u)
  ctx.beginPath(); ctx.arc(mx, my, moonR, Math.atan2(uy, ux) - Math.PI / 2, Math.atan2(uy, ux) + Math.PI / 2); ctx.fill();

  // ground-view inset when an eclipse is underway
  if (type > 0) {
    const ir = Math.min(W, H) * 0.085, ix = W * 0.5, iy = H * 0.18;
    ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.beginPath(); ctx.arc(ix, iy, ir * 1.5, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.stroke();
    if (type === 3) {
      const cg = ctx.createRadialGradient(ix, iy, ir * 0.55, ix, iy, ir * 1.3);
      cg.addColorStop(0, "rgba(255,255,255,0.95)"); cg.addColorStop(1, "rgba(200,220,255,0)");
      ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(ix, iy, ir * 1.3, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(ix, iy, ir * 0.6, 0, Math.PI * 2); ctx.fill();
    } else if (type === 2) {
      ctx.fillStyle = "#ffd34d"; ctx.beginPath(); ctx.arc(ix, iy, ir * 0.7, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(ix, iy, ir * 0.52, 0, Math.PI * 2); ctx.fill();
    } else {
      ctx.fillStyle = "#ffd34d"; ctx.beginPath(); ctx.arc(ix, iy, ir * 0.7, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(ix + ir * 0.45, iy - ir * 0.2, ir * 0.62, 0, Math.PI * 2); ctx.fill();
    }
    const names = ["", "PARTIAL ECLIPSE", "ANNULAR — ring of fire", "TOTAL ECLIPSE"];
    ctx.fillStyle = type === 3 ? "#fff" : "#ffd98a";
    ctx.font = "bold 12px system-ui, sans-serif"; ctx.textAlign = "center";
    ctx.fillText(names[type], ix, iy + ir * 1.5 + 16);
    ctx.textAlign = "left";
    if (type === 3) { ctx.fillStyle = "rgba(255,255,255,0.08)"; ctx.fillRect(0, 0, W, H); }
  }

  // HUD
  ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(PAD, PAD, 200, 80);
  ctx.font = "12px monospace"; ctx.fillStyle = "#dfe7ff";
  ctx.fillText(`months   ${months}`, PAD + 10, PAD + 18);
  ctx.fillText(`eclipses ${eclipses}`, PAD + 10, PAD + 34);
  ctx.fillText(`tilt     ${tiltDeg.toFixed(1)}°`, PAD + 10, PAD + 50);
  ctx.fillStyle = type ? "#ffd98a" : "rgba(180,200,240,0.7)";
  ctx.fillText(type ? "shadow on Earth!" : "shadow misses Earth", PAD + 10, PAD + 68);
  ctx.fillStyle = "rgba(180,200,240,0.55)"; ctx.font = "11px system-ui, sans-serif";
  ctx.fillText("drag near Earth: Moon closer/farther (total vs annular)", PAD, H - PAD - BTN - 10);
  // tilt buttons
  for (const [x, lab] of [[mX, "−"], [pX, "+"]]) {
    ctx.fillStyle = "rgba(0,0,0,0.65)"; ctx.fillRect(x, bY, BTN, BTN);
    ctx.strokeStyle = "rgba(255,255,255,0.45)"; ctx.strokeRect(x + 0.5, bY + 0.5, BTN - 1, BTN - 1);
    ctx.fillStyle = "#fff"; ctx.font = "bold 24px system-ui, sans-serif";
    ctx.textAlign = "center"; ctx.textBaseline = "middle";
    ctx.fillText(lab, x + BTN / 2, bY + BTN / 2);
    ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
  }
  ctx.fillStyle = "rgba(180,200,240,0.55)"; ctx.font = "11px system-ui, sans-serif";
  ctx.textAlign = "right"; ctx.fillText("tilt", mX - 8, bY + BTN / 2 + 4); ctx.textAlign = "left";
}

Comments (0)

Log in to comment.