3

Tidal Locking: Why the Moon Has One Face

tap to kick the spin

Our Moon always shows Earth the same face — a coincidence? No: it's the equilibrium of a slow gravitational brake called **tidal locking**. The planet's gravity raises a small bulge on the moon along the planet–moon line. If the moon spins faster than it orbits, internal friction drags that bulge slightly *ahead* of the line by a lag angle proportional to , where is the spin rate and the orbital rate. The planet then pulls back harder on the lagging side, producing a torque that **brakes the spin**. Spin too slow () and the lag flips sign, *speeding up* the moon. Either way the system relaxes toward . Watch the red marker on the moon: at first it sweeps freely past the planet, then drifts more slowly, then finally stays fixed — pointing forever at one face. The lower panel plots , exponentially asymptoting to zero. For Earth's Moon this process took roughly a billion years; for hot Jupiters orbiting close to their stars, it takes only a few million. Tap the canvas to kick the spin to a fresh value and watch it re-lock.

idle
235 lines · vanilla
view source
// Tidal locking: a moon's spin synchronizes with its orbital period because
// the tidal bulge it raises on itself lags the planet–moon line by a small
// angle proportional to (omega - Omega). That lag gives gravity a lever arm,
// producing a torque that brakes (or accelerates) the spin toward synchrony.
//
// Equations (simplified, dimensionless):
//   theta_orbit'   = Omega          (constant circular orbit)
//   theta_spin'    = omega
//   lag_angle      = K_lag * (omega - Omega)        (bulge trails the line)
//   torque         = -K_tau * sin(2 * lag_angle)    (sin(2x) ~ tidal sym.)
//   omega'         = torque / I

const ORBIT_PERIOD = 6.0;          // seconds for moon to circle planet
const OMEGA_ORBIT = (2 * Math.PI) / ORBIT_PERIOD;
const MOON_MOMENT = 1.0;           // moment of inertia (arbitrary unit)
const K_LAG = 0.18;                // bulge lag per unit (omega - Omega)
const K_TAU = 0.85;                // tidal torque magnitude
const MAX_LAG = 0.45;              // clamp lag so the cartoon stays readable
const HISTORY_LEN = 360;           // samples in the omega-Omega plot

let W = 0, H = 0;
let scenePanel, plotPanel;
let planetCenter, orbitR, planetR, moonR;

let thetaOrbit;      // moon's position around planet (rad)
let thetaSpin;       // moon's intrinsic spin angle (rad)
let omegaSpin;       // moon's spin rate (rad/s)
let lagAngle;        // signed lag of the bulge behind the planet–moon line

let history;         // Float32Array of (omega - Omega) over time
let histIdx;
let histCount;
let timeAccum;

let prevDown = false;
let starField;       // tiny twinkle backdrop

function layout() {
  const pad = 10;
  const plotH = Math.max(110, Math.min(170, Math.round(H * 0.28)));
  scenePanel = { x: pad, y: pad, w: W - 2 * pad, h: H - plotH - 3 * pad };
  plotPanel  = { x: pad, y: scenePanel.y + scenePanel.h + pad,
                 w: W - 2 * pad, h: plotH };

  planetCenter = { x: scenePanel.x + scenePanel.w * 0.5,
                   y: scenePanel.y + scenePanel.h * 0.5 };
  const minSide = Math.min(scenePanel.w, scenePanel.h);
  orbitR  = minSide * 0.36;
  planetR = minSide * 0.10;
  moonR   = minSide * 0.055;
}

function seedStars(n) {
  starField = new Float32Array(n * 3); // x, y, brightness
  for (let i = 0; i < n; i++) {
    starField[i * 3]     = Math.random();          // normalized 0..1
    starField[i * 3 + 1] = Math.random();
    starField[i * 3 + 2] = 0.25 + Math.random() * 0.75;
  }
}

function resetState(kickRandom) {
  thetaOrbit = 0;
  thetaSpin  = 0;
  // Initial omega: deliberately off-synchrony so the user sees relocking.
  if (kickRandom) {
    // sign random; magnitude 2..4x orbital rate
    const sign = Math.random() < 0.5 ? -1 : 1;
    omegaSpin = sign * OMEGA_ORBIT * (2.0 + Math.random() * 2.0);
  } else {
    omegaSpin = OMEGA_ORBIT * 3.2; // initial unlocked spin
  }
  lagAngle = 0;
  history = new Float32Array(HISTORY_LEN);
  histIdx = 0;
  histCount = 0;
  timeAccum = 0;
}

function init({ width, height }) {
  W = width; H = height;
  layout();
  seedStars(140);
  resetState(false);
}

function pushHistory(v) {
  history[histIdx] = v;
  histIdx = (histIdx + 1) % HISTORY_LEN;
  if (histCount < HISTORY_LEN) histCount++;
}

function stepPhysics(h) {
  // Orbital motion (fixed circular).
  thetaOrbit += OMEGA_ORBIT * h;

  // Bulge lag: trails the planet-moon line by an angle ~ (omega - Omega).
  const dOmega = omegaSpin - OMEGA_ORBIT;
  let lag = K_LAG * dOmega;
  if (lag >  MAX_LAG) lag =  MAX_LAG;
  if (lag < -MAX_LAG) lag = -MAX_LAG;
  lagAngle = lag;

  // Tidal torque. sin(2*lag) captures the symmetric two-bulge geometry:
  // small lag ~ -K_TAU * 2 * lag, restoring toward omega = Omega.
  const torque = -K_TAU * Math.sin(2 * lag);

  omegaSpin += (torque / MOON_MOMENT) * h;
  thetaSpin += omegaSpin * h;
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    layout();
  }

  // Click = kick the spin to a random value.
  if (input && typeof input.consumeClicks === 'function') {
    const clicks = input.consumeClicks();
    if (clicks > 0) resetState(true);
  } else if (input && input.mouseDown && !prevDown) {
    resetState(true);
  }
  prevDown = !!(input && input.mouseDown);

  // Sub-step the physics so the locking dynamics stay stable at any FPS.
  const subSteps = 4;
  const h = Math.min(dt, 1 / 30) / subSteps;
  for (let i = 0; i < subSteps; i++) stepPhysics(h);

  timeAccum += dt;
  // ~60 samples per second of history regardless of frame rate.
  while (timeAccum > 1 / 60) {
    pushHistory(omegaSpin - OMEGA_ORBIT);
    timeAccum -= 1 / 60;
  }

  // ---- Background ----
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = '#05070d';
  ctx.fillRect(0, 0, W, H);

  drawScene(ctx);
  drawPlot(ctx);
}

function drawScene(ctx) {
  const sp = scenePanel;

  // Panel frame.
  ctx.fillStyle = '#070912';
  ctx.fillRect(sp.x, sp.y, sp.w, sp.h);

  // Stars.
  for (let i = 0; i < starField.length / 3; i++) {
    const sx = sp.x + starField[i * 3]     * sp.w;
    const sy = sp.y + starField[i * 3 + 1] * sp.h;
    const b  = starField[i * 3 + 2];
    ctx.fillStyle = `rgba(220,230,255,${(0.25 + b * 0.55).toFixed(3)})`;
    ctx.fillRect(sx, sy, 1, 1);
  }

  // Orbit ring (faint).
  ctx.strokeStyle = 'rgba(140,170,220,0.18)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.arc(planetCenter.x, planetCenter.y, orbitR, 0, Math.PI * 2);
  ctx.stroke();

  // Planet.
  const pg = ctx.createRadialGradient(
    planetCenter.x - planetR * 0.4, planetCenter.y - planetR * 0.4, planetR * 0.2,
    planetCenter.x, planetCenter.y, planetR
  );
  pg.addColorStop(0, '#5da7ff');
  pg.addColorStop(0.55, '#2c5fc4');
  pg.addColorStop(1, '#0a1d44');
  ctx.fillStyle = pg;
  ctx.beginPath();
  ctx.arc(planetCenter.x, planetCenter.y, planetR, 0, Math.PI * 2);
  ctx.fill();

  // Moon position.
  const mx = planetCenter.x + Math.cos(thetaOrbit) * orbitR;
  const my = planetCenter.y + Math.sin(thetaOrbit) * orbitR;

  // The planet-moon line angle (from the moon, looking toward the planet).
  // The bulge axis is along this line, plus the lag angle.
  const planetDirAtMoon = Math.atan2(planetCenter.y - my, planetCenter.x - mx);
  const bulgeAxis = planetDirAtMoon + lagAngle;

  // Moon body (slightly elongated along bulgeAxis to suggest the tidal bulge).
  const elong = 1.18; // along bulge axis
  ctx.save();
  ctx.translate(mx, my);
  ctx.rotate(bulgeAxis);
  const mg = ctx.createRadialGradient(-moonR * 0.3, -moonR * 0.3, moonR * 0.2,
                                      0, 0, moonR * elong);
  mg.addColorStop(0, '#e8e2cf');
  mg.addColorStop(0.65, '#9b937c');
  mg.addColorStop(1, '#403a2d');
  ctx.fillStyle = mg;
  ctx.beginPath();
  ctx.ellipse(0, 0, moonR * elong, moonR, 0, 0, Math.PI * 2);
  ctx.fill();

  // Faint bulge-axis indicator (a hairline through the moon).
  ctx.strokeStyle = 'rgba(255,200,120,0.45)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(-moonR * elong * 1.05, 0);
  ctx.lineTo( moonR * elong * 1.05, 0);
  ctx.stroke();
  ctx.restore();

  // "Near side" surface marker: a colored stripe rotating with the moon's
  // spin. When omega = Omega this stripe always points at the planet.
  ctx.save();
  ctx.translate(mx, my);
  ctx.rotate(thetaSpin);
  // Crater dot (the "feature").
  ctx.fillStyle = '#e34a4a';
  ctx.beginPath();
  ctx.arc(moonR * 0.55, 0, moonR * 0.18, 0, Math.PI * 2);
  ctx.fill();
  // Stripe.
  ctx.strokeStyle = 'rgba(230,80,80,0.85)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(0, 0, moonR * 0.78, -0.55, 0.55);
  ctx.stroke();
  ctx.restore();

  // Connecting line planet -> moon (very subtle reference for the eye).
  ctx.strokeStyle = 'rgba(180,200,240,0.10)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(planetCenter.x, planetCenter.y);
  ctx.lineTo(mx, my);
  ctx.stroke();

  // HUD text.
  const dOmega = omegaSpin - OMEGA_ORBIT;
  const ratio = omegaSpin / OMEGA_ORBIT;
  const locked = Math.abs(dOmega) < 0.05;

  ctx.fillStyle = 'rgba(220,230,250,0.85)';
  ctx.font = '12px system-ui, sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillText(`spin/orbit ratio  ω/Ω = ${ratio.toFixed(2)}`, sp.x + 10, sp.y + 8);
  ctx.fillText(`bulge lag         = ${(lagAngle * 180 / Math.PI).toFixed(1)}°`,
               sp.x + 10, sp.y + 24);
  ctx.fillStyle = locked ? 'rgba(120,230,150,0.95)' : 'rgba(255,200,120,0.85)';
  ctx.fillText(locked ? 'tidally locked' : 'spinning down…',
               sp.x + 10, sp.y + 40);

  ctx.textAlign = 'right';
  ctx.fillStyle = 'rgba(180,200,240,0.55)';
  ctx.fillText('tap to kick the spin', sp.x + sp.w - 10, sp.y + 8);
  ctx.textAlign = 'left';
}

function drawPlot(ctx) {
  const p = plotPanel;

  ctx.fillStyle = '#07090f';
  ctx.fillRect(p.x, p.y, p.w, p.h);
  ctx.strokeStyle = 'rgba(140,170,220,0.18)';
  ctx.lineWidth = 1;
  ctx.strokeRect(p.x + 0.5, p.y + 0.5, p.w - 1, p.h - 1);

  // Find scale: cover the largest absolute value in history (plus margin).
  let maxAbs = 0.3;
  for (let i = 0; i < histCount; i++) {
    const v = history[i];
    const a = v >= 0 ? v : -v;
    if (a > maxAbs) maxAbs = a;
  }
  maxAbs *= 1.1;

  const midY = p.y + p.h * 0.5;
  // Zero line.
  ctx.strokeStyle = 'rgba(120,230,150,0.45)';
  ctx.setLineDash([3, 4]);
  ctx.beginPath();
  ctx.moveTo(p.x + 6, midY);
  ctx.lineTo(p.x + p.w - 6, midY);
  ctx.stroke();
  ctx.setLineDash([]);

  // History trace, oldest -> newest, left -> right.
  if (histCount > 1) {
    ctx.strokeStyle = 'rgba(255,210,140,0.95)';
    ctx.lineWidth = 1.6;
    ctx.beginPath();
    const start = (histIdx - histCount + HISTORY_LEN) % HISTORY_LEN;
    for (let i = 0; i < histCount; i++) {
      const idx = (start + i) % HISTORY_LEN;
      const v = history[idx];
      const x = p.x + 6 + (i / (HISTORY_LEN - 1)) * (p.w - 12);
      const y = midY - (v / maxAbs) * (p.h * 0.5 - 10);
      if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  // Labels.
  ctx.fillStyle = 'rgba(220,230,250,0.85)';
  ctx.font = '11px system-ui, sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillText('ω − Ω  vs time', p.x + 8, p.y + 6);
  ctx.textBaseline = 'middle';
  ctx.fillStyle = 'rgba(120,230,150,0.75)';
  ctx.fillText('0 (locked)', p.x + 8, midY);
}

Comments (0)

Log in to comment.