Two double pendulums start from positions that differ by a tiny angle offset (default: 0.001 radians â about the width of a human hair at arm's length). Press Release and watch them evolve. The red pendulum is the reference; the blue one starts almost identically. Within seconds they are doing completely different things.
<div class="controls">
<label>Offset <span id="offsetVal">0.001</span> rad
<input type="range" id="offsetSlider" min="0" max="0.1" step="0.001" value="0.001">
</label>
<button id="releaseBtn" type="button">Release</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="480" height="360"></canvas>
<div class="info">
<span id="timeEl">t = 0.00 s</span>
<span id="sepEl">separation: 0.000 rad</span>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f4f6f8; color: #222; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
padding: .5rem .6rem; background: #e8eef3; border-bottom: 1px solid #cdd9e3; }
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 120px; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#canvas { display: block; background: #fff; border-bottom: 1px solid #e0e4e8; }
.info { display: flex; gap: 1.2rem; padding: .4rem .7rem; font-size: .82rem;
background: #f9fafb; border-top: 1px solid #e8eef3; color: #555; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Double pendulum simulation using 4th-order Runge-Kutta.
// State: [theta1, omega1, theta2, omega2] (angles from downward vertical)
// Equations derived from the Lagrangian for m1=m2=m, L1=L2=L:
// System: [2 cd][a1] = [-w2^2*sd - 2g/L*sin(t1)]
// [cd 1][a2] [ w1^2*sd - g/L*sin(t2) ]
// where d=t1-t2, sd=sin(d), cd=cos(d), det=2-cd^2.
// Energy E = (mL^2/2)*(2w1^2+w2^2+2w1*w2*cd) - mgL*(2cos(t1)+cos(t2)) is conserved.
const g = 9.81, L = 1;
function deriv(s) {
const [t1, w1, t2, w2] = s;
const d = t1 - t2;
const sd = Math.sin(d), cd = Math.cos(d);
const det = 2 - cd * cd;
const rhs1 = -w2 * w2 * sd - 2 * (g / L) * Math.sin(t1);
const rhs2 = w1 * w1 * sd - (g / L) * Math.sin(t2);
const a1 = (rhs1 - cd * rhs2) / det;
const a2 = (2 * rhs2 - cd * rhs1) / det;
return [w1, a1, w2, a2];
}
function rk4(s, dt) {
const k1 = deriv(s);
const k2 = deriv(s.map((v,i) => v + dt/2*k1[i]));
const k3 = deriv(s.map((v,i) => v + dt/2*k2[i]));
const k4 = deriv(s.map((v,i) => v + dt*k3[i]));
return s.map((v,i) => v + dt/6*(k1[i]+2*k2[i]+2*k3[i]+k4[i]));
}
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const cx = W/2, cy = 120;
const PX = 110; // pixels per unit length
let state1, state2, t, running, rafId;
const trail1 = [], trail2 = [];
const TRAIL_LEN = 200;
function initStates() {
const offset = parseFloat(document.getElementById('offsetSlider').value);
state1 = [Math.PI/2, 0, Math.PI/2, 0];
state2 = [Math.PI/2 + offset, 0, Math.PI/2, 0];
t = 0; trail1.length = 0; trail2.length = 0;
}
function pendPos(s) {
const [t1,,t2] = s;
const x1 = cx + PX*Math.sin(t1);
const y1 = cy + PX*Math.cos(t1);
const x2 = x1 + PX*Math.sin(t2);
const y2 = y1 + PX*Math.cos(t2);
return { x1, y1, x2, y2 };
}
function drawPendulum(s, color, alpha) {
const { x1, y1, x2, y2 } = pendPos(s);
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x1, y1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x1, y1, 7, 0, 2*Math.PI); ctx.fill();
ctx.beginPath(); ctx.arc(x2, y2, 9, 0, 2*Math.PI); ctx.fill();
ctx.restore();
}
function drawTrail(trail, color) {
if (trail.length < 2) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
for (let i = 1; i < trail.length; i++) {
ctx.globalAlpha = i / trail.length * 0.55;
ctx.beginPath();
ctx.moveTo(trail[i-1].x, trail[i-1].y);
ctx.lineTo(trail[i].x, trail[i].y);
ctx.stroke();
}
ctx.restore();
}
function draw() {
ctx.clearRect(0, 0, W, H);
// pivot
ctx.fillStyle = '#888';
ctx.beginPath(); ctx.arc(cx, cy, 5, 0, 2*Math.PI); ctx.fill();
drawTrail(trail1, '#e63946');
drawTrail(trail2, '#1d6fa5');
drawPendulum(state1, '#e63946', 0.9);
drawPendulum(state2, '#1d6fa5', 0.9);
// separation indicator bar
const sep = Math.sqrt(
(state1[0]-state2[0])**2 + (state1[2]-state2[2])**2
);
const barW = Math.min(sep / 0.5, 1) * (W - 20);
ctx.fillStyle = `hsl(${Math.round((1 - Math.min(sep/0.5,1))*120)},70%,45%)`;
ctx.fillRect(10, H-18, barW, 8);
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.strokeRect(10, H-18, W-20, 8);
document.getElementById('timeEl').textContent = 't = ' + t.toFixed(2) + ' s';
document.getElementById('sepEl').textContent = fmt('separation: %sep% rad', { sep: sep.toFixed(3) });
}
function step() {
const DT = 0.005;
const SUBSTEPS = 4;
for (let i = 0; i < SUBSTEPS; i++) {
state1 = rk4(state1, DT/SUBSTEPS);
state2 = rk4(state2, DT/SUBSTEPS);
t += DT/SUBSTEPS;
}
const p1 = pendPos(state1), p2 = pendPos(state2);
trail1.push({x: p1.x2, y: p1.y2});
trail2.push({x: p2.x2, y: p2.y2});
if (trail1.length > TRAIL_LEN) trail1.shift();
if (trail2.length > TRAIL_LEN) trail2.shift();
draw();
if (running) rafId = requestAnimationFrame(step);
}
document.getElementById('offsetSlider').addEventListener('input', function() {
document.getElementById('offsetVal').textContent = parseFloat(this.value).toFixed(3);
if (!running) { initStates(); draw(); }
});
document.getElementById('releaseBtn').addEventListener('click', function() {
if (running) {
running = false;
cancelAnimationFrame(rafId);
this.textContent = 'Release';
} else {
initStates();
running = true;
this.textContent = 'Pause';
rafId = requestAnimationFrame(step);
}
});
document.getElementById('resetBtn').addEventListener('click', function() {
running = false;
cancelAnimationFrame(rafId);
document.getElementById('releaseBtn').textContent = 'Release';
initStates();
draw();
});
initStates();
draw();
<div class="controls">
<label>Offset <span id="offsetVal">0.001</span> rad
<input type="range" id="offsetSlider" min="0" max="0.1" step="0.001" value="0.001">
</label>
<button id="releaseBtn" type="button">Release</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="480" height="360"></canvas>
<div class="info">
<span id="timeEl">t = 0.00 s</span>
<span id="sepEl">separation: 0.000 rad</span>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f4f6f8; color: #222; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
padding: .5rem .6rem; background: #e8eef3; border-bottom: 1px solid #cdd9e3; }
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 120px; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#canvas { display: block; background: #fff; border-bottom: 1px solid #e0e4e8; }
.info { display: flex; gap: 1.2rem; padding: .4rem .7rem; font-size: .82rem;
background: #f9fafb; border-top: 1px solid #e8eef3; color: #555; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Double pendulum simulation using 4th-order Runge-Kutta.
// State: [theta1, omega1, theta2, omega2] (angles from downward vertical)
// Equations derived from the Lagrangian for m1=m2=m, L1=L2=L:
// System: [2 cd][a1] = [-w2^2*sd - 2g/L*sin(t1)]
// [cd 1][a2] [ w1^2*sd - g/L*sin(t2) ]
// where d=t1-t2, sd=sin(d), cd=cos(d), det=2-cd^2.
// Energy E = (mL^2/2)*(2w1^2+w2^2+2w1*w2*cd) - mgL*(2cos(t1)+cos(t2)) is conserved.
const g = 9.81, L = 1;
function deriv(s) {
const [t1, w1, t2, w2] = s;
const d = t1 - t2;
const sd = Math.sin(d), cd = Math.cos(d);
const det = 2 - cd * cd;
const rhs1 = -w2 * w2 * sd - 2 * (g / L) * Math.sin(t1);
const rhs2 = w1 * w1 * sd - (g / L) * Math.sin(t2);
const a1 = (rhs1 - cd * rhs2) / det;
const a2 = (2 * rhs2 - cd * rhs1) / det;
return [w1, a1, w2, a2];
}
function rk4(s, dt) {
const k1 = deriv(s);
const k2 = deriv(s.map((v,i) => v + dt/2*k1[i]));
const k3 = deriv(s.map((v,i) => v + dt/2*k2[i]));
const k4 = deriv(s.map((v,i) => v + dt*k3[i]));
return s.map((v,i) => v + dt/6*(k1[i]+2*k2[i]+2*k3[i]+k4[i]));
}
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const cx = W/2, cy = 120;
const PX = 110; // pixels per unit length
let state1, state2, t, running, rafId;
const trail1 = [], trail2 = [];
const TRAIL_LEN = 200;
function initStates() {
const offset = parseFloat(document.getElementById('offsetSlider').value);
state1 = [Math.PI/2, 0, Math.PI/2, 0];
state2 = [Math.PI/2 + offset, 0, Math.PI/2, 0];
t = 0; trail1.length = 0; trail2.length = 0;
}
function pendPos(s) {
const [t1,,t2] = s;
const x1 = cx + PX*Math.sin(t1);
const y1 = cy + PX*Math.cos(t1);
const x2 = x1 + PX*Math.sin(t2);
const y2 = y1 + PX*Math.cos(t2);
return { x1, y1, x2, y2 };
}
function drawPendulum(s, color, alpha) {
const { x1, y1, x2, y2 } = pendPos(s);
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x1, y1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x1, y1, 7, 0, 2*Math.PI); ctx.fill();
ctx.beginPath(); ctx.arc(x2, y2, 9, 0, 2*Math.PI); ctx.fill();
ctx.restore();
}
function drawTrail(trail, color) {
if (trail.length < 2) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
for (let i = 1; i < trail.length; i++) {
ctx.globalAlpha = i / trail.length * 0.55;
ctx.beginPath();
ctx.moveTo(trail[i-1].x, trail[i-1].y);
ctx.lineTo(trail[i].x, trail[i].y);
ctx.stroke();
}
ctx.restore();
}
function draw() {
ctx.clearRect(0, 0, W, H);
// pivot
ctx.fillStyle = '#888';
ctx.beginPath(); ctx.arc(cx, cy, 5, 0, 2*Math.PI); ctx.fill();
drawTrail(trail1, '#e63946');
drawTrail(trail2, '#1d6fa5');
drawPendulum(state1, '#e63946', 0.9);
drawPendulum(state2, '#1d6fa5', 0.9);
// separation indicator bar
const sep = Math.sqrt(
(state1[0]-state2[0])**2 + (state1[2]-state2[2])**2
);
const barW = Math.min(sep / 0.5, 1) * (W - 20);
ctx.fillStyle = `hsl(${Math.round((1 - Math.min(sep/0.5,1))*120)},70%,45%)`;
ctx.fillRect(10, H-18, barW, 8);
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.strokeRect(10, H-18, W-20, 8);
document.getElementById('timeEl').textContent = 't = ' + t.toFixed(2) + ' s';
document.getElementById('sepEl').textContent = fmt('separation: %sep% rad', { sep: sep.toFixed(3) });
}
function step() {
const DT = 0.005;
const SUBSTEPS = 4;
for (let i = 0; i < SUBSTEPS; i++) {
state1 = rk4(state1, DT/SUBSTEPS);
state2 = rk4(state2, DT/SUBSTEPS);
t += DT/SUBSTEPS;
}
const p1 = pendPos(state1), p2 = pendPos(state2);
trail1.push({x: p1.x2, y: p1.y2});
trail2.push({x: p2.x2, y: p2.y2});
if (trail1.length > TRAIL_LEN) trail1.shift();
if (trail2.length > TRAIL_LEN) trail2.shift();
draw();
if (running) rafId = requestAnimationFrame(step);
}
document.getElementById('offsetSlider').addEventListener('input', function() {
document.getElementById('offsetVal').textContent = parseFloat(this.value).toFixed(3);
if (!running) { initStates(); draw(); }
});
document.getElementById('releaseBtn').addEventListener('click', function() {
if (running) {
running = false;
cancelAnimationFrame(rafId);
this.textContent = 'Release';
} else {
initStates();
running = true;
this.textContent = 'Pause';
rafId = requestAnimationFrame(step);
}
});
document.getElementById('resetBtn').addEventListener('click', function() {
running = false;
cancelAnimationFrame(rafId);
document.getElementById('releaseBtn').textContent = 'Release';
initStates();
draw();
});
initStates();
draw();
Comments
Loading comments...