6
Tides: Why Two Bulges a Day
drag to move the Moon closer/farther; tap 'sun' for spring/neap tides
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.