6
Solar Eclipse Simulator: Umbra and Penumbra
drag near Earth to move the Moon closer/farther; +/- adjusts orbital tilt
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.