<p class="hint">Two trajectories start 0.001 apart in x. Blue = reference path, Red = perturbed path. Watch them trace the Lorenz butterfly together — then fly apart. That is the butterfly effect.</p>
<canvas id="canvas" width="560" height="300"></canvas>
<div class="controls">
<button id="btnRun" type="button">Run</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
<label>Speed <input type="range" id="speed" min="1" max="12" value="4"></label>
</div>
<div id="status" class="status">Press Run to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; background: #0d1117; border-radius: 8px; width: 100%; max-width: 560px; }
.controls { display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin: .6rem 0; }
button { font: 600 14px system-ui; padding: .4rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
label { font-size: .88rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 90px; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; color: #333; }
.status.diverged { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Lorenz system: dx/dt = s(y-x), dy/dt = x(r-z)-y, dz/dt = xy-bz
// Classic parameters: sigma=10, rho=28, beta=8/3.
// Two trajectories start at (0,1,0) and (0.001,1,0).
// We integrate with 4th-order Runge-Kutta and project onto a 2D canvas.
const S = 10, R = 28, B = 8/3;
function lorenz(x, y, z) {
return [S*(y-x), x*(R-z)-y, x*y-B*z];
}
function rk4(x, y, z, dt) {
const [k1x,k1y,k1z] = lorenz(x,y,z);
const [k2x,k2y,k2z] = lorenz(x+dt/2*k1x, y+dt/2*k1y, z+dt/2*k1z);
const [k3x,k3y,k3z] = lorenz(x+dt/2*k2x, y+dt/2*k2y, z+dt/2*k2z);
const [k4x,k4y,k4z] = lorenz(x+dt*k3x, y+dt*k3y, z+dt*k3z);
return [
x + dt/6*(k1x+2*k2x+2*k3x+k4x),
y + dt/6*(k1y+2*k2y+2*k3y+k4y),
z + dt/6*(k1z+2*k2z+2*k3z+k4z)
];
}
// Project 3D Lorenz state onto 2D canvas.
// We use x-axis → canvas x, z-axis → canvas y (the classic butterfly view).
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Lorenz attractor lives roughly x∈[-20,20], z∈[0,50].
function project(lx, lz) {
const cx = (lx + 25) / 50 * W;
const cy = H - (lz / 55) * H;
return [cx, cy];
}
const DT = 0.005;
let state1, state2, trail1, trail2, animId, running, t;
const MAX_TRAIL = 3000;
function initState() {
state1 = [0, 1, 0];
state2 = [0.001, 1, 0]; // perturbed by 0.001 in x
trail1 = [];
trail2 = [];
t = 0;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Draw trail1 (blue)
if (trail1.length > 1) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(80,160,255,0.75)';
ctx.lineWidth = 1.2;
const start = Math.max(0, trail1.length - MAX_TRAIL);
const [x0, y0] = project(trail1[start][0], trail1[start][2]);
ctx.moveTo(x0, y0);
for (let i = start+1; i < trail1.length; i++) {
const [px, py] = project(trail1[i][0], trail1[i][2]);
ctx.lineTo(px, py);
}
ctx.stroke();
}
// Draw trail2 (red)
if (trail2.length > 1) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(230,60,60,0.75)';
ctx.lineWidth = 1.2;
const start = Math.max(0, trail2.length - MAX_TRAIL);
const [x0, y0] = project(trail2[start][0], trail2[start][2]);
ctx.moveTo(x0, y0);
for (let i = start+1; i < trail2.length; i++) {
const [px, py] = project(trail2[i][0], trail2[i][2]);
ctx.lineTo(px, py);
}
ctx.stroke();
}
// Draw current heads
if (trail1.length) {
const [px, py] = project(state1[0], state1[2]);
ctx.beginPath(); ctx.arc(px,py,3.5,0,2*Math.PI);
ctx.fillStyle='#50a0ff'; ctx.fill();
}
if (trail2.length) {
const [px, py] = project(state2[0], state2[2]);
ctx.beginPath(); ctx.arc(px,py,3.5,0,2*Math.PI);
ctx.fillStyle='#e63c3c'; ctx.fill();
}
}
function dist3(a, b) {
return Math.sqrt((a[0]-b[0])**2+(a[1]-b[1])**2+(a[2]-b[2])**2);
}
function step() {
const stepsPerFrame = parseInt(document.getElementById('speed').value, 10);
for (let i = 0; i < stepsPerFrame * 3; i++) {
trail1.push([...state1]);
trail2.push([...state2]);
state1 = rk4(...state1, DT);
state2 = rk4(...state2, DT);
t += DT;
}
draw();
const d = dist3(state1, state2);
const initialD = 0.001;
const status = document.getElementById('status');
if (d > 5) {
status.textContent = fmt('t = %t% — Paths have diverged! Distance = %d% (started at %initialD%)', { t: t.toFixed(1), d: d.toFixed(2), initialD: initialD });
status.className = 'status diverged';
} else {
status.textContent = fmt('t = %t% — Distance between paths: %toExponential%', { t: t.toFixed(1), toExponential: d.toExponential(3) });
status.className = 'status';
}
animId = requestAnimationFrame(step);
}
function startRun() {
if (running) return;
running = true;
document.getElementById('btnRun').textContent = 'Running…';
document.getElementById('btnRun').disabled = true;
animId = requestAnimationFrame(step);
}
function reset() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
running = false;
document.getElementById('btnRun').textContent = 'Run';
document.getElementById('btnRun').disabled = false;
initState();
draw();
document.getElementById('status').textContent = 'Press Run to start.';
document.getElementById('status').className = 'status';
}
document.getElementById('btnRun').addEventListener('click', startRun);
document.getElementById('btnReset').addEventListener('click', reset);
initState();
draw();
// Node.js verification shim (ignored in browser)
if (typeof module !== 'undefined') {
module.exports = { rk4, lorenz, dist3 };
}
<p class="hint">Two trajectories start 0.001 apart in x. Blue = reference path, Red = perturbed path. Watch them trace the Lorenz butterfly together — then fly apart. That is the butterfly effect.</p>
<canvas id="canvas" width="560" height="300"></canvas>
<div class="controls">
<button id="btnRun" type="button">Run</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
<label>Speed <input type="range" id="speed" min="1" max="12" value="4"></label>
</div>
<div id="status" class="status">Press Run to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; background: #0d1117; border-radius: 8px; width: 100%; max-width: 560px; }
.controls { display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin: .6rem 0; }
button { font: 600 14px system-ui; padding: .4rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
label { font-size: .88rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 90px; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; color: #333; }
.status.diverged { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Lorenz system: dx/dt = s(y-x), dy/dt = x(r-z)-y, dz/dt = xy-bz
// Classic parameters: sigma=10, rho=28, beta=8/3.
// Two trajectories start at (0,1,0) and (0.001,1,0).
// We integrate with 4th-order Runge-Kutta and project onto a 2D canvas.
const S = 10, R = 28, B = 8/3;
function lorenz(x, y, z) {
return [S*(y-x), x*(R-z)-y, x*y-B*z];
}
function rk4(x, y, z, dt) {
const [k1x,k1y,k1z] = lorenz(x,y,z);
const [k2x,k2y,k2z] = lorenz(x+dt/2*k1x, y+dt/2*k1y, z+dt/2*k1z);
const [k3x,k3y,k3z] = lorenz(x+dt/2*k2x, y+dt/2*k2y, z+dt/2*k2z);
const [k4x,k4y,k4z] = lorenz(x+dt*k3x, y+dt*k3y, z+dt*k3z);
return [
x + dt/6*(k1x+2*k2x+2*k3x+k4x),
y + dt/6*(k1y+2*k2y+2*k3y+k4y),
z + dt/6*(k1z+2*k2z+2*k3z+k4z)
];
}
// Project 3D Lorenz state onto 2D canvas.
// We use x-axis → canvas x, z-axis → canvas y (the classic butterfly view).
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Lorenz attractor lives roughly x∈[-20,20], z∈[0,50].
function project(lx, lz) {
const cx = (lx + 25) / 50 * W;
const cy = H - (lz / 55) * H;
return [cx, cy];
}
const DT = 0.005;
let state1, state2, trail1, trail2, animId, running, t;
const MAX_TRAIL = 3000;
function initState() {
state1 = [0, 1, 0];
state2 = [0.001, 1, 0]; // perturbed by 0.001 in x
trail1 = [];
trail2 = [];
t = 0;
}
function draw() {
ctx.clearRect(0, 0, W, H);
// Draw trail1 (blue)
if (trail1.length > 1) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(80,160,255,0.75)';
ctx.lineWidth = 1.2;
const start = Math.max(0, trail1.length - MAX_TRAIL);
const [x0, y0] = project(trail1[start][0], trail1[start][2]);
ctx.moveTo(x0, y0);
for (let i = start+1; i < trail1.length; i++) {
const [px, py] = project(trail1[i][0], trail1[i][2]);
ctx.lineTo(px, py);
}
ctx.stroke();
}
// Draw trail2 (red)
if (trail2.length > 1) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(230,60,60,0.75)';
ctx.lineWidth = 1.2;
const start = Math.max(0, trail2.length - MAX_TRAIL);
const [x0, y0] = project(trail2[start][0], trail2[start][2]);
ctx.moveTo(x0, y0);
for (let i = start+1; i < trail2.length; i++) {
const [px, py] = project(trail2[i][0], trail2[i][2]);
ctx.lineTo(px, py);
}
ctx.stroke();
}
// Draw current heads
if (trail1.length) {
const [px, py] = project(state1[0], state1[2]);
ctx.beginPath(); ctx.arc(px,py,3.5,0,2*Math.PI);
ctx.fillStyle='#50a0ff'; ctx.fill();
}
if (trail2.length) {
const [px, py] = project(state2[0], state2[2]);
ctx.beginPath(); ctx.arc(px,py,3.5,0,2*Math.PI);
ctx.fillStyle='#e63c3c'; ctx.fill();
}
}
function dist3(a, b) {
return Math.sqrt((a[0]-b[0])**2+(a[1]-b[1])**2+(a[2]-b[2])**2);
}
function step() {
const stepsPerFrame = parseInt(document.getElementById('speed').value, 10);
for (let i = 0; i < stepsPerFrame * 3; i++) {
trail1.push([...state1]);
trail2.push([...state2]);
state1 = rk4(...state1, DT);
state2 = rk4(...state2, DT);
t += DT;
}
draw();
const d = dist3(state1, state2);
const initialD = 0.001;
const status = document.getElementById('status');
if (d > 5) {
status.textContent = fmt('t = %t% — Paths have diverged! Distance = %d% (started at %initialD%)', { t: t.toFixed(1), d: d.toFixed(2), initialD: initialD });
status.className = 'status diverged';
} else {
status.textContent = fmt('t = %t% — Distance between paths: %toExponential%', { t: t.toFixed(1), toExponential: d.toExponential(3) });
status.className = 'status';
}
animId = requestAnimationFrame(step);
}
function startRun() {
if (running) return;
running = true;
document.getElementById('btnRun').textContent = 'Running…';
document.getElementById('btnRun').disabled = true;
animId = requestAnimationFrame(step);
}
function reset() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
running = false;
document.getElementById('btnRun').textContent = 'Run';
document.getElementById('btnRun').disabled = false;
initState();
draw();
document.getElementById('status').textContent = 'Press Run to start.';
document.getElementById('status').className = 'status';
}
document.getElementById('btnRun').addEventListener('click', startRun);
document.getElementById('btnReset').addEventListener('click', reset);
initState();
draw();
// Node.js verification shim (ignored in browser)
if (typeof module !== 'undefined') {
module.exports = { rk4, lorenz, dist3 };
}
This is sensitive dependence on initial conditions — the hallmark of chaos. No matter how close the two starts, they will eventually separate. Doubling measurement precision only buys a tiny extra window of prediction.
Comments
Loading comments...