6

Tides: Why Two Bulges a Day

drag to move the Moon closer/farther; tap 'sun' for spring/neap tides

A tides simulator answering the classic puzzle of why there are two high tides a day: the Moon's differential gravity stretches the ocean into two bulges — the near side is pulled more than Earth's center, the far side less — shown by the gray force arrows around the exaggerated ocean shell. The red city rides Earth's rotation through both bulges, and the strip chart below traces its sea level: two highs per day, with tidal range scaling as in the Moon's distance. Drag the Moon closer or farther to crank the tides, and toggle the Sun's contribution to watch spring and neap tides emerge as the envelopes beat against each other.

idle
142 lines · vanilla
view source
// Tides: the Moon's differential gravity raises TWO bulges (near side pulled
// more than center, far side pulled less). Earth rotates through both -> two
// high tides a day. Toggle the Sun for spring/neap.
const DAY = 4;                       // real seconds per Earth day
const WS = 2 * Math.PI / DAY;        // spin rate
const WM = 2 * Math.PI / (27.3 * DAY);
const NCH = 480, SAMP = 1 / 8;       // chart: 60 s = 15 days
const BTN_W = 96, BTN_H = 44, PAD = 12;
let W = 0, H = 0;
let spin, moonAng, sunOn, dER, sampAcc;
let chart, chHead;
let stars;

function p2(c) { return (3 * c * c - 1) / 2; }
function tideM(cityA, moonA, d, sun) {
  let h = 0.37 * Math.pow(60 / d, 3) * p2(Math.cos(cityA - moonA));
  if (sun) h += 0.17 * p2(Math.cos(cityA - Math.PI));
  return h;
}
function init({ width, height }) {
  W = width; H = height;
  spin = 0.7; moonAng = 0.5; sunOn = false; dER = 60; sampAcc = 0;
  chart = new Float32Array(NCH); chHead = 0;
  for (let i = 0; i < NCH; i++) {              // pre-seed history: alive at frame 1
    const t = -(NCH - 1 - i) * SAMP;
    chart[i] = tideM(spin + WS * t, moonAng + WM * t, dER, sunOn);
  }
  chHead = 0; // ring writes continue from index 0 (oldest = chart[chHead])
  stars = new Float32Array(130 * 3);
  for (let i = 0; i < 130; i++) {
    stars[i * 3] = Math.random(); stars[i * 3 + 1] = Math.random();
    stars[i * 3 + 2] = 0.2 + Math.random() * 0.8;
  }
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  const chartH = Math.max(80, Math.min(130, Math.round(H * 0.22)));
  const cx = W / 2, cy = (H - chartH) * 0.52;
  const S = Math.min(W, H - chartH);
  const Re = S * 0.135;
  const visMin = S * 0.30, visMax = S * 0.46;
  const btn = { x: W - BTN_W - PAD, y: 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)
      sunOn = !sunOn;
  }
  if (input.mouseDown && !(input.mouseX >= btn.x - 8 && input.mouseY <= btn.y + btn.h + 8)) {
    const r = Math.hypot(input.mouseX - cx, input.mouseY - cy);
    const f = Math.max(0, Math.min(1, (r - visMin) / (visMax - visMin)));
    dER = 40 + f * 50;                          // 40..90 Earth radii
  }
  spin += WS * dt; moonAng += WM * dt;
  sampAcc += dt;
  while (sampAcc >= SAMP) {
    sampAcc -= SAMP;
    chart[chHead] = tideM(spin, moonAng, dER, sunOn);
    chHead = (chHead + 1) % NCH;
  }
  const ampScale = Math.pow(60 / dER, 3);
  const visR = visMin + (dER - 40) / 50 * (visMax - visMin);
  const mx = cx + Math.cos(moonAng) * visR, my = cy + Math.sin(moonAng) * visR;

  // ---- scene ----
  ctx.fillStyle = "#05070d"; ctx.fillRect(0, 0, W, H);
  for (let i = 0; i < 130; 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 - chartH), 1, 1);
  }
  if (sunOn) {                                  // Sun far off to the left
    const sg = ctx.createRadialGradient(0, cy, 4, 0, cy, S * 0.30);
    sg.addColorStop(0, "#fff3b0"); sg.addColorStop(0.4, "rgba(255,180,40,0.7)"); sg.addColorStop(1, "rgba(255,140,30,0)");
    ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(0, cy, S * 0.30, 0, Math.PI * 2); ctx.fill();
  }
  // ocean shell: thick base + exaggerated tidal field (never dips below Re)
  const ampM = Re * 0.26 * Math.min(2.5, ampScale), ampS = Re * 0.12;
  ctx.fillStyle = "rgba(60,140,255,0.55)";
  ctx.beginPath();
  for (let i = 0; i <= 90; i++) {
    const a = i / 90 * 2 * Math.PI;
    let h = ampM * p2(Math.cos(a - moonAng));
    if (sunOn) h += ampS * p2(Math.cos(a - Math.PI));
    const r = Re * 1.32 + h;
    const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
    if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
  }
  ctx.closePath(); ctx.fill();
  // differential-force arrows (why the FAR bulge exists)
  ctx.strokeStyle = "rgba(200,205,220,0.6)"; ctx.lineWidth = 1.5;
  const aLen = Re * 0.16;
  for (let i = 0; i < 12; i++) {
    const a = i / 12 * 2 * Math.PI, rp = a - moonAng;
    const vx0 = 2 * Math.cos(rp), vy0 = -Math.sin(rp);   // tidal field, moon frame
    const vx = vx0 * Math.cos(moonAng) - vy0 * Math.sin(moonAng);
    const vy = vx0 * Math.sin(moonAng) + vy0 * Math.cos(moonAng);
    const x0 = cx + Math.cos(a) * Re * 1.8, y0 = cy + Math.sin(a) * Re * 1.8;
    const x1 = x0 + vx * aLen, y1 = y0 + vy * aLen;
    ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
    ctx.beginPath(); ctx.arc(x1, y1, 1.8, 0, Math.PI * 2); ctx.fillStyle = "rgba(200,205,220,0.6)"; ctx.fill();
  }
  // Earth + rotating city marker
  const eg = ctx.createRadialGradient(cx - Re * 0.3, cy - Re * 0.3, 2, cx, cy, Re);
  eg.addColorStop(0, "#8fd0ff"); eg.addColorStop(0.55, "#2b6fd4"); eg.addColorStop(1, "#0a2a66");
  ctx.fillStyle = eg; ctx.beginPath(); ctx.arc(cx, cy, Re, 0, Math.PI * 2); ctx.fill();
  const cxx = cx + Math.cos(spin) * Re, cyy = cy + Math.sin(spin) * Re;
  ctx.fillStyle = "#ff5b4d"; ctx.beginPath(); ctx.arc(cxx, cyy, 5, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = "rgba(255,91,77,0.5)";
  ctx.beginPath(); ctx.moveTo(cx + Math.cos(spin) * Re * 0.7, cy + Math.sin(spin) * Re * 0.7);
  ctx.lineTo(cxx, cyy); ctx.stroke();
  // Moon
  ctx.strokeStyle = "rgba(140,170,220,0.18)";
  ctx.beginPath(); ctx.arc(cx, cy, visR, 0, Math.PI * 2); ctx.stroke();
  const mg = ctx.createRadialGradient(mx - 4, my - 4, 1, mx, my, Re * 0.28);
  mg.addColorStop(0, "#f2ead0"); mg.addColorStop(1, "#6e684f");
  ctx.fillStyle = mg; ctx.beginPath(); ctx.arc(mx, my, Re * 0.28, 0, Math.PI * 2); ctx.fill();

  // ---- strip chart ----
  const chY = H - chartH;
  ctx.fillStyle = "#070a12"; ctx.fillRect(0, chY, W, chartH);
  ctx.strokeStyle = "rgba(140,170,220,0.2)";
  ctx.beginPath(); ctx.moveTo(0, chY + 0.5); ctx.lineTo(W, chY + 0.5); ctx.stroke();
  let maxAbs = 0.3;
  for (let i = 0; i < NCH; i++) { const v = Math.abs(chart[i]); if (v > maxAbs) maxAbs = v; }
  const mid = chY + chartH / 2;
  ctx.strokeStyle = "rgba(255,255,255,0.15)"; ctx.setLineDash([3, 4]);
  ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(W, mid); ctx.stroke(); ctx.setLineDash([]);
  ctx.strokeStyle = "#5db4ff"; ctx.lineWidth = 1.6;
  ctx.beginPath();
  for (let i = 0; i < NCH; i++) {
    const v = chart[(chHead + i) % NCH];
    const x = i / (NCH - 1) * W, y = mid - v / (maxAbs * 1.15) * (chartH / 2 - 8);
    if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
  }
  ctx.stroke();
  ctx.fillStyle = "rgba(200,215,245,0.6)"; ctx.font = "11px system-ui, sans-serif";
  ctx.fillText(`sea level at the red city — last 15 days ${sunOn ? "(watch spring/neap)" : ""}`, 8, chY + 14);

  // ---- HUD ----
  const hNow = tideM(spin, moonAng, dER, sunOn);
  const u = ((spin - moonAng) % Math.PI + Math.PI) % Math.PI;
  const nextH = ((Math.PI - u) % Math.PI) / (WS - WM) * (24 / DAY);
  ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(PAD, PAD, 198, 80);
  ctx.font = "12px monospace"; ctx.fillStyle = "#dfe7ff";
  ctx.fillText(`tide      ${hNow >= 0 ? "+" : ""}${hNow.toFixed(2)} m`, PAD + 10, PAD + 18);
  ctx.fillText(`next high in ${nextH.toFixed(1)} h`, PAD + 10, PAD + 36);
  ctx.fillText(`moon dist ${dER.toFixed(0)} R⊕`, PAD + 10, PAD + 54);
  ctx.fillStyle = "#ffd98a"; ctx.fillText("range ∝ 1/r³", PAD + 10, PAD + 72);
  ctx.fillStyle = "rgba(180,200,240,0.55)"; ctx.font = "11px system-ui, sans-serif";
  ctx.fillText("drag: Moon closer/farther", PAD, chY - 8);
  // sun toggle button
  ctx.fillStyle = sunOn ? "rgba(220,160,40,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(sunOn ? "sun: ON" : "sun: OFF", btn.x + btn.w / 2, btn.y + btn.h / 2);
  ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
}

Comments (0)

Log in to comment.