<p class="hint">Three bodies pulling on each other with Newton's gravity — no formula, just physics stepping forward in time. Launch the figure-eight choreography, then <b>Perturb</b> one body to see chaos take over.</p>
<canvas id="c" width="480" height="360"></canvas>
<div class="btns">
<button id="btn-launch" type="button">Launch</button>
<button id="btn-perturb" type="button">Perturb</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status">Press Launch to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f4f6f8; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; width: 100%; max-width: 480px; border-radius: 10px;
background: #0d0d1a; margin: 0 auto .5rem; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; margin-bottom: .4rem; }
button { font: 600 14px system-ui; padding: .42rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .88rem; font-weight: 600; text-align: center; min-height: 1.3em;
color: #1d3557; }
// Three-body gravity simulation using Runge-Kutta 4 (RK4).
// Initial conditions: Chenciner-Montgomery figure-eight orbit (2000).
// Positions and velocities from the literature (units: G=1, total mass=3).
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const CX = W / 2, CY = H / 2;
const SCALE = 110; // world units → pixels
const TRAIL = 300; // trail length in frames
const DT = 0.005; // time step
const STEPS_PER_FRAME = 8;
// Figure-eight initial conditions (Chenciner & Montgomery 2000)
// Period T ≈ 6.3259, G=1, m=1 each.
const FIG8 = [
{ x: -0.97000436, y: 0.24308753, vx: 0.93240737/2, vy: 0.86473146/2 },
{ x: 0.97000436, y: -0.24308753, vx: 0.93240737/2, vy: 0.86473146/2 },
{ x: 0, y: 0, vx: -0.93240737, vy: -0.86473146 }
];
const COLORS = ['#ff6b6b', '#4ecdc4', '#ffe66d'];
const G = 1, m = 1;
let bodies = [], trails = [], running = false, rafId = null, t = 0;
function copyFig8() {
return FIG8.map(b => ({ x: b.x, y: b.y, vx: b.vx, vy: b.vy }));
}
function reset() {
bodies = copyFig8();
trails = [[], [], []];
t = 0;
cancelAnimationFrame(rafId);
running = false;
draw();
document.getElementById('status').textContent = 'Press Launch to start.';
}
// Compute accelerations for a given state array [{x,y,vx,vy}, ...]
function accel(state) {
const ax = [0, 0, 0], ay = [0, 0, 0];
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (i === j) continue;
const dx = state[j].x - state[i].x;
const dy = state[j].y - state[i].y;
const r2 = dx * dx + dy * dy + 1e-6; // softening
const r3 = Math.pow(r2, 1.5);
ax[i] += G * m * dx / r3;
ay[i] += G * m * dy / r3;
}
}
return { ax, ay };
}
// RK4 step
function rk4(state, dt) {
function deriv(s) {
const { ax, ay } = accel(s);
return s.map((b, i) => ({ dx: b.vx, dy: b.vy, dvx: ax[i], dvy: ay[i] }));
}
function addScaled(s, d, h) {
return s.map((b, i) => ({
x: b.x + d[i].dx * h, y: b.y + d[i].dy * h,
vx: b.vx + d[i].dvx * h, vy: b.vy + d[i].dvy * h
}));
}
const k1 = deriv(state);
const k2 = deriv(addScaled(state, k1, dt / 2));
const k3 = deriv(addScaled(state, k2, dt / 2));
const k4 = deriv(addScaled(state, k3, dt));
return state.map((b, i) => ({
x: b.x + (k1[i].dx + 2*k2[i].dx + 2*k3[i].dx + k4[i].dx) * dt / 6,
y: b.y + (k1[i].dy + 2*k2[i].dy + 2*k3[i].dy + k4[i].dy) * dt / 6,
vx: b.vx + (k1[i].dvx + 2*k2[i].dvx + 2*k3[i].dvx + k4[i].dvx) * dt / 6,
vy: b.vy + (k1[i].dvy + 2*k2[i].dvy + 2*k3[i].dvy + k4[i].dvy) * dt / 6
}));
}
function worldToScreen(x, y) {
return { sx: CX + x * SCALE, sy: CY - y * SCALE };
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0d0d1a';
ctx.fillRect(0, 0, W, H);
// Draw trails
for (let i = 0; i < 3; i++) {
const tr = trails[i];
if (tr.length < 2) continue;
for (let k = 1; k < tr.length; k++) {
const alpha = k / tr.length;
ctx.beginPath();
ctx.strokeStyle = COLORS[i] + Math.round(alpha * 180).toString(16).padStart(2, '00');
ctx.lineWidth = 1.2;
ctx.moveTo(tr[k-1].sx, tr[k-1].sy);
ctx.lineTo(tr[k].sx, tr[k].sy);
ctx.stroke();
}
}
// Draw bodies
for (let i = 0; i < 3; i++) {
const { sx, sy } = worldToScreen(bodies[i].x, bodies[i].y);
ctx.beginPath();
ctx.arc(sx, sy, 7, 0, Math.PI * 2);
ctx.fillStyle = COLORS[i];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function step() {
for (let s = 0; s < STEPS_PER_FRAME; s++) {
bodies = rk4(bodies, DT);
t += DT;
}
for (let i = 0; i < 3; i++) {
const pt = worldToScreen(bodies[i].x, bodies[i].y);
trails[i].push(pt);
if (trails[i].length > TRAIL) trails[i].shift();
}
}
function loop() {
if (!running) return;
step();
draw();
rafId = requestAnimationFrame(loop);
}
document.getElementById('btn-launch').addEventListener('click', () => {
running = true;
document.getElementById('status').textContent = 'Figure-eight orbit — stable choreography.';
loop();
});
document.getElementById('btn-perturb').addEventListener('click', () => {
// Nudge body 0 by a tiny amount — triggers chaos
bodies[0].x += 0.005;
bodies[0].y += 0.003;
document.getElementById('status').textContent = 'Perturbed! Tiny nudge → chaotic orbit (Lyapunov divergence).';
if (!running) { running = true; loop(); }
});
document.getElementById('btn-reset').addEventListener('click', reset);
// Verify: advance one full period (T≈6.3260) and confirm each body
// returns to its own starting position (figure-eight symmetry).
(function selfTest() {
let s = copyFig8();
const T_APPROX = 6.3260;
const steps = Math.round(T_APPROX / DT); // 1265 steps
for (let i = 0; i < steps; i++) s = rk4(s, DT);
const dist = Math.hypot(s[0].x - FIG8[0].x, s[0].y - FIG8[0].y);
if (dist > 0.05) {
console.warn('Period test: dist =', dist.toFixed(5), '— expected < 0.05');
} else {
console.log('Period self-test passed: body0 returns to start after 1T. dist =', dist.toFixed(5));
}
})();
reset();
Comments
Loading comments...