3
Tidal Locking: Why the Moon Has One Face
tap to kick the spin
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.