6

Kepler's Laws: Equal Areas, Elliptical Orbits

drag up/down to set eccentricity; tap '2nd planet' for the third law

A Kepler's laws simulation with a planet on a genuinely eccentric ellipse (Kepler's equation solved each frame), the Sun at one focus and the empty focus ghosted. The second law is drawn live: the translucent wedge is the area swept in a fixed — fat and short at perihelion, long and skinny at aphelion — and the HUD shows holding constant at while the speed-colored trail burns red at perihelion and cools to blue at aphelion. Drag vertically to set eccentricity up to 0.85, and toggle a second planet at to verify the third law, , with both periods in the HUD.

idle
145 lines · vanilla
view source
// Kepler's laws on an analytic two-body orbit (Newton-solved Kepler equation).
// Law 2 drawn live: the area swept in a fixed dt window is a constant.
const MU = 1, A1 = 1, TSCALE = 2 * Math.PI / 7;  // T1 = 7 real seconds
const WIN = 0.7;                                  // swept-window, sim-time units
const NPATH = 240, CAP = 512;
const BTN_W = 110, BTN_H = 44, PAD = 12;
let W = 0, H = 0;
let ecc, M1, M2, E1, E2, second, simT;
let path1, path2, col1;                           // orbit polylines + colors
let ringX, ringY, ringT, head, count;             // wedge ring buffer
let vmin, vmax;

function kepE(M, e, guess) {
  let E = guess;
  for (let i = 0; i < 8; i++) E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
  return E;
}
function buildPaths() {
  vmin = Math.sqrt(MU * (2 / (A1 * (1 + ecc)) - 1 / A1));
  vmax = Math.sqrt(MU * (2 / (A1 * (1 - ecc)) - 1 / A1));
  for (let i = 0; i < NPATH; i++) {
    const E = i / NPATH * 2 * Math.PI;
    const b = Math.sqrt(1 - ecc * ecc);
    path1[i * 2] = A1 * (Math.cos(E) - ecc); path1[i * 2 + 1] = A1 * b * Math.sin(E);
    path2[i * 2] = 2 * A1 * (Math.cos(E) - ecc); path2[i * 2 + 1] = 2 * A1 * b * Math.sin(E);
    const r = A1 * (1 - ecc * Math.cos(E));
    const v = Math.sqrt(MU * (2 / r - 1 / A1));
    const t = vmax > vmin ? (v - vmin) / (vmax - vmin) : 0.5;
    col1[i] = `hsl(${(230 - 230 * t).toFixed(0)},90%,${(55 + t * 12).toFixed(0)}%)`;
  }
}
function init({ width, height }) {
  W = width; H = height;
  ecc = 0.6; M1 = 1.2; M2 = 0.4; E1 = M1; E2 = M2;
  second = false; simT = 0;
  path1 = new Float32Array(NPATH * 2); path2 = new Float32Array(NPATH * 2);
  col1 = new Array(NPATH);
  ringX = new Float32Array(CAP); ringY = new Float32Array(CAP); ringT = new Float32Array(CAP);
  head = 0; count = 0;
  buildPaths();
}
function planetPos(a, e, E, out) {
  out[0] = a * (Math.cos(E) - e); out[1] = a * Math.sqrt(1 - e * e) * Math.sin(E);
}
const P1 = [0, 0], P2 = [0, 0];

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  const btn = { x: W - BTN_W - PAD, y: H - BTN_H - PAD, w: BTN_W, h: BTN_H };
  for (const c of input.consumeClicks()) {
    if (c.x >= btn.x && c.x <= btn.x + btn.w && c.y >= btn.y && c.y <= btn.y + btn.h)
      second = !second;
  }
  // drag vertically anywhere else -> eccentricity 0..0.85
  if (input.mouseDown && !(input.mouseX >= btn.x && input.mouseY >= btn.y - 8)) {
    const e2 = Math.max(0, Math.min(0.85, (1 - input.mouseY / H) * 1.05));
    if (Math.abs(e2 - ecc) > 0.002) {
      ecc = e2; buildPaths(); head = 0; count = 0;   // re-launch wedge
    }
  }
  // advance orbits
  const h = Math.min(dt, 0.05) * TSCALE;
  simT += h;
  M1 += h * Math.sqrt(MU / (A1 ** 3));
  M2 += h * Math.sqrt(MU / ((2 * A1) ** 3));
  const m1 = M1 % (2 * Math.PI), m2 = M2 % (2 * Math.PI);
  E1 = kepE(m1, ecc, m1 + ecc * Math.sin(m1));
  E2 = kepE(m2, ecc, m2 + ecc * Math.sin(m2));
  planetPos(A1, ecc, E1, P1); planetPos(2 * A1, ecc, E2, P2);
  const r1 = Math.hypot(P1[0], P1[1]);
  const v1 = Math.sqrt(MU * (2 / r1 - 1 / A1));
  // wedge ring buffer
  ringX[head] = P1[0]; ringY[head] = P1[1]; ringT[head] = simT;
  head = (head + 1) % CAP; if (count < CAP) count++;

  // layout: fit the active system
  const amax = (second ? 2 : 1) * A1 * (1 + ecc);
  const sc = (Math.min(W, H) / 2 - 28) / amax;
  const fx = W / 2 + (second ? 2 : 1) * A1 * ecc * sc * 0.5, fy = H / 2; // focus (Sun)

  ctx.fillStyle = "#05060c"; ctx.fillRect(0, 0, W, H);
  // ellipse outlines (faint) + speed-colored trail for planet 1
  ctx.lineWidth = 1; ctx.strokeStyle = "rgba(140,160,210,0.18)";
  if (second) {
    ctx.beginPath();
    for (let i = 0; i <= NPATH; i++) {
      const j = i % NPATH, x = fx + path2[j * 2] * sc, y = fy - path2[j * 2 + 1] * sc;
      if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    ctx.stroke();
  }
  ctx.lineWidth = 2.2; ctx.lineCap = "round";
  for (let i = 0; i < NPATH; i++) {
    const j = (i + 1) % NPATH;
    ctx.strokeStyle = col1[i];
    ctx.beginPath();
    ctx.moveTo(fx + path1[i * 2] * sc, fy - path1[i * 2 + 1] * sc);
    ctx.lineTo(fx + path1[j * 2] * sc, fy - path1[j * 2 + 1] * sc);
    ctx.stroke();
  }
  // swept-area wedge over the last WIN sim-seconds
  let area = 0, oldest = -1;
  ctx.fillStyle = "rgba(255,210,90,0.30)";
  ctx.beginPath(); ctx.moveTo(fx, fy);
  for (let i = count - 1; i >= 0; i--) {
    const idx = (head - 1 - i + 2 * CAP) % CAP;
    if (simT - ringT[idx] > WIN) continue;
    if (oldest < 0) oldest = idx;
    ctx.lineTo(fx + ringX[idx] * sc, fy - ringY[idx] * sc);
  }
  ctx.closePath(); ctx.fill();
  if (oldest >= 0) {
    let px = ringX[oldest], py = ringY[oldest], started = false;
    for (let i = count - 1; i >= 0; i--) {
      const idx = (head - 1 - i + 2 * CAP) % CAP;
      if (simT - ringT[idx] > WIN) continue;
      if (started) area += 0.5 * (px * ringY[idx] - py * ringX[idx]);
      px = ringX[idx]; py = ringY[idx]; started = true;
    }
  }
  area = Math.abs(area);
  // foci: Sun at one, empty focus ghosted
  const c1 = A1 * ecc;
  ctx.strokeStyle = "rgba(160,180,230,0.4)"; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(fx - 2 * c1 * sc, fy, 4, 0, Math.PI * 2); ctx.stroke();
  const sg = ctx.createRadialGradient(fx, fy, 1, fx, fy, 14);
  sg.addColorStop(0, "#fff7c0"); sg.addColorStop(0.5, "#ffb627"); sg.addColorStop(1, "rgba(255,120,20,0)");
  ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(fx, fy, 14, 0, Math.PI * 2); ctx.fill();
  // planets
  const t1 = vmax > vmin ? (v1 - vmin) / (vmax - vmin) : 0.5;
  ctx.fillStyle = `hsl(${(230 - 230 * t1).toFixed(0)},95%,70%)`;
  ctx.beginPath(); ctx.arc(fx + P1[0] * sc, fy - P1[1] * sc, 6, 0, Math.PI * 2); ctx.fill();
  if (second) {
    ctx.fillStyle = "#9ad6a8";
    ctx.beginPath(); ctx.arc(fx + P2[0] * sc, fy - P2[1] * sc, 5, 0, Math.PI * 2); ctx.fill();
  }
  // HUD
  const T1 = 7.0, T2 = 7.0 * Math.pow(2, 1.5);
  ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(PAD, PAD, 212, second ? 100 : 68);
  ctx.font = "12px monospace"; ctx.fillStyle = "#dfe7ff"; ctx.textAlign = "left";
  ctx.fillText(`e = ${ecc.toFixed(2)}   v = ${v1.toFixed(2)}`, PAD + 10, PAD + 18);
  ctx.fillStyle = "#ffd98a";
  ctx.fillText(`dA/dt = ${(area / WIN).toFixed(3)}  ← constant`, PAD + 10, PAD + 36);
  ctx.fillStyle = "#dfe7ff";
  ctx.fillText(`L/2    = ${(0.5 * Math.sqrt(MU * A1 * (1 - ecc * ecc))).toFixed(3)}`, PAD + 10, PAD + 54);
  if (second) {
    ctx.fillStyle = "#9ad6a8";
    ctx.fillText(`T₁=${T1.toFixed(1)}s  T₂=${T2.toFixed(1)}s`, PAD + 10, PAD + 72);
    ctx.fillText(`T²/a³: ${(T1 * T1 / 1).toFixed(0)} = ${(T2 * T2 / 8).toFixed(0)} ✓`, PAD + 10, PAD + 90);
  }
  ctx.fillStyle = "rgba(180,200,240,0.55)"; ctx.font = "11px system-ui, sans-serif";
  ctx.fillText("drag up/down: eccentricity", PAD, H - PAD - 4);
  // button
  ctx.fillStyle = second ? "rgba(80,160,110,0.85)" : "rgba(0,0,0,0.65)";
  ctx.fillRect(btn.x, btn.y, btn.w, btn.h);
  ctx.strokeStyle = "rgba(255,255,255,0.45)"; 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(second ? "2nd planet ✓" : "2nd planet", btn.x + btn.w / 2, btn.y + btn.h / 2);
  ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
}

Comments (0)

Log in to comment.