Below, a particle orbits a fixed center under an inverse-square force (like gravity or Coulomb attraction). Both integrators start with identical conditions; only the update rule differs.
<div class="controls">
<label>Time step (dt): <span id="dtVal">0.04</span>
<input type="range" id="dtSlider" min="1" max="8" value="4" step="1">
</label>
<div class="btns">
<button id="startBtn">Run</button>
<button id="resetBtn" class="ghost">Reset</button>
</div>
</div>
<div class="panels">
<div class="orbit-panel">
<div class="panel-label">Orbit path</div>
<canvas id="orbitCanvas" width="260" height="260"></canvas>
</div>
<div class="energy-panel">
<div class="panel-label">Energy over time</div>
<canvas id="energyCanvas" width="260" height="130"></canvas>
<div class="legend">
<span class="dot euler"></span>Euler
<span class="dot verlet"></span>Verlet
</div>
</div>
</div>
<div class="status" id="status">Press Run to start the simulation.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.controls label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 110px; accent-color: #1d3557; }
.btns { display: flex; gap: .4rem; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.panels { display: flex; flex-wrap: wrap; gap: .8rem; }
.orbit-panel, .energy-panel { display: flex; flex-direction: column; gap: .2rem; }
.panel-label { font-size: .75rem; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #d0d7de; border-radius: 8px; background: #f8fafc; display: block; }
.legend { font-size: .8rem; margin-top: .3rem; display: flex; align-items: center; gap: .2rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.dot.euler { background: #e63946; }
.dot.verlet { background: #1d7f47; }
.status { font-size: .85rem; font-weight: 600; margin-top: .4rem; min-height: 1.2em; color: #444; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Orbit demo: compare Euler and Verlet (position form) for a 2-D inverse-square
// central force. F = -GM/r^2 directed toward origin; GM = 1.
// Euler: x += v*dt, v += a*dt (not symplectic → energy drifts up)
// Verlet: x_new = 2*x - x_prev + a*dt^2 (symplectic → shadow energy conserved)
const GM = 1.0;
const DT_TABLE = [0.005, 0.01, 0.02, 0.04, 0.06, 0.09, 0.13, 0.18];
// Shared initial conditions (circular orbit at radius r0).
const R0 = 0.5;
const V_CIRC = Math.sqrt(GM / R0); // speed for a perfect circular orbit
function accel(x, y) {
const r2 = x * x + y * y;
const r = Math.sqrt(r2);
const f = -GM / r2;
return [f * x / r, f * y / r];
}
function energy(x, y, vx, vy) {
const r = Math.sqrt(x * x + y * y);
return 0.5 * (vx * vx + vy * vy) - GM / r; // KE + PE
}
// State
let running = false;
let animId = null;
let dt = 0.04;
let step = 0;
// Euler state
let ex, ey, evx, evy;
// Verlet state (needs prev + current position; velocity derived for energy)
let vx_pos, vy_pos, vx_prev, vy_prev, vvx, vvy;
const HIST_MAX = 400; // energy history length
let eulerEnergy = [];
let verletEnergy = [];
let eulerTrail = [];
let verletTrail = [];
const TRAIL_MAX = 800;
function initState() {
// Euler: start at (R0, 0), velocity = (0, V_CIRC)
ex = R0; ey = 0; evx = 0; evy = V_CIRC;
// Verlet: bootstrap previous position using one backward Euler step
vx_pos = R0; vy_pos = 0;
vvx = 0; vvy = V_CIRC; // store velocity for energy calc
const [ax0, ay0] = accel(vx_pos, vy_pos);
// x_prev = x - v*dt + 0.5*a*dt^2 (from Taylor expansion)
vx_prev = vx_pos - vvx * dt + 0.5 * ax0 * dt * dt;
vy_prev = vy_pos - vvy * dt + 0.5 * ay0 * dt * dt;
eulerEnergy = [energy(ex, ey, evx, evy)];
verletEnergy = [energy(vx_pos, vy_pos, vvx, vvy)];
eulerTrail = [[ex, ey]];
verletTrail = [[vx_pos, vy_pos]];
step = 0;
}
function stepEuler() {
const [ax, ay] = accel(ex, ey);
ex += evx * dt;
ey += evy * dt;
evx += ax * dt;
evy += ay * dt;
}
function stepVerlet() {
const [ax, ay] = accel(vx_pos, vy_pos);
const nx = 2 * vx_pos - vx_prev + ax * dt * dt;
const ny = 2 * vy_pos - vy_prev + ay * dt * dt;
// Approximate velocity for energy (central difference)
vvx = (nx - vx_prev) / (2 * dt);
vvy = (ny - vy_prev) / (2 * dt);
vx_prev = vx_pos; vy_prev = vy_pos;
vx_pos = nx; vy_pos = ny;
}
// Canvas helpers
const orbitC = document.getElementById('orbitCanvas');
const energyC = document.getElementById('energyCanvas');
const octx = orbitC.getContext('2d');
const ectx = energyC.getContext('2d');
const OW = orbitC.width, OH = orbitC.height;
const EW = energyC.width, EH = energyC.height;
const SCALE = 110; // world units → pixels; center = (OW/2, OH/2)
function worldToCanvas(x, y) {
return [OW / 2 + x * SCALE, OH / 2 - y * SCALE];
}
function drawOrbit() {
octx.clearRect(0, 0, OW, OH);
// Center star
octx.beginPath();
octx.arc(OW / 2, OH / 2, 5, 0, Math.PI * 2);
octx.fillStyle = '#f4a261';
octx.fill();
function drawTrail(trail, color) {
if (trail.length < 2) return;
octx.beginPath();
const [x0, y0] = worldToCanvas(trail[0][0], trail[0][1]);
octx.moveTo(x0, y0);
for (let i = 1; i < trail.length; i++) {
const [xi, yi] = worldToCanvas(trail[i][0], trail[i][1]);
octx.lineTo(xi, yi);
}
octx.strokeStyle = color;
octx.lineWidth = 1.5;
octx.globalAlpha = 0.7;
octx.stroke();
octx.globalAlpha = 1;
}
drawTrail(eulerTrail, '#e63946');
drawTrail(verletTrail, '#1d7f47');
// Current positions
function drawDot(trail, color) {
if (!trail.length) return;
const [x, y] = worldToCanvas(trail[trail.length - 1][0], trail[trail.length - 1][1]);
octx.beginPath();
octx.arc(x, y, 5, 0, Math.PI * 2);
octx.fillStyle = color;
octx.fill();
}
drawDot(eulerTrail, '#e63946');
drawDot(verletTrail, '#1d7f47');
}
function drawEnergy() {
ectx.clearRect(0, 0, EW, EH);
const all = [...eulerEnergy, ...verletEnergy];
const eMin = Math.min(...all) - 0.05;
const eMax = Math.max(...all, eMin + 0.01) + 0.05;
const range = eMax - eMin;
function toECanvas(idx, e) {
const n = Math.max(eulerEnergy.length, verletEnergy.length);
const x = (idx / (HIST_MAX - 1)) * (EW - 20) + 10;
const y = EH - 10 - ((e - eMin) / range) * (EH - 20);
return [x, y];
}
function drawLine(arr, color) {
if (arr.length < 2) return;
ectx.beginPath();
const [x0, y0] = toECanvas(0, arr[0]);
ectx.moveTo(x0, y0);
for (let i = 1; i < arr.length; i++) {
const [xi, yi] = toECanvas(i, arr[i]);
ectx.lineTo(xi, yi);
}
ectx.strokeStyle = color;
ectx.lineWidth = 1.5;
ectx.stroke();
}
// Zero-energy line (approximate equilibrium)
const e0 = eulerEnergy[0];
const [, yz] = toECanvas(0, e0);
ectx.beginPath();
ectx.moveTo(10, yz); ectx.lineTo(EW - 10, yz);
ectx.strokeStyle = '#bbb';
ectx.lineWidth = 0.8;
ectx.setLineDash([4, 3]);
ectx.stroke();
ectx.setLineDash([]);
drawLine(eulerEnergy, '#e63946');
drawLine(verletEnergy, '#1d7f47');
}
function tick() {
const STEPS_PER_FRAME = 6;
for (let i = 0; i < STEPS_PER_FRAME; i++) {
stepEuler();
stepVerlet();
step++;
eulerTrail.push([ex, ey]);
if (eulerTrail.length > TRAIL_MAX) eulerTrail.shift();
verletTrail.push([vx_pos, vy_pos]);
if (verletTrail.length > TRAIL_MAX) verletTrail.shift();
if (step % 3 === 0) {
eulerEnergy.push(energy(ex, ey, evx, evy));
if (eulerEnergy.length > HIST_MAX) eulerEnergy.shift();
verletEnergy.push(energy(vx_pos, vy_pos, vvx, vvy));
if (verletEnergy.length > HIST_MAX) verletEnergy.shift();
}
}
drawOrbit();
drawEnergy();
// Report last energies
const ee = eulerEnergy[eulerEnergy.length - 1];
const ve = verletEnergy[verletEnergy.length - 1];
const e0 = eulerEnergy[0] || 1;
const drift = ((ee - e0) / Math.abs(e0) * 100).toFixed(1);
document.getElementById('status').textContent =
fmt('Step %step% | Euler E: %ee% (drift %drift%%) | Verlet E: %ve%', { step: step, ee: ee.toFixed(4), drift: drift, ve: ve.toFixed(4) });
if (running) animId = requestAnimationFrame(tick);
}
function reset() {
running = false;
if (animId) cancelAnimationFrame(animId);
animId = null;
dt = DT_TABLE[parseInt(document.getElementById('dtSlider').value) - 1];
initState();
drawOrbit();
drawEnergy();
document.getElementById('status').textContent = 'Press Run to start the simulation.';
document.getElementById('startBtn').textContent = 'Run';
}
document.getElementById('dtSlider').addEventListener('input', function () {
dt = DT_TABLE[parseInt(this.value) - 1];
document.getElementById('dtVal').textContent = dt.toFixed(3);
if (!running) {
initState();
drawOrbit();
drawEnergy();
}
});
document.getElementById('startBtn').addEventListener('click', function () {
if (running) {
running = false;
if (animId) cancelAnimationFrame(animId);
animId = null;
this.textContent = 'Run';
} else {
running = true;
this.textContent = 'Pause';
tick();
}
});
document.getElementById('resetBtn').addEventListener('click', reset);
// Init
reset();
<div class="controls">
<label>Time step (dt): <span id="dtVal">0.04</span>
<input type="range" id="dtSlider" min="1" max="8" value="4" step="1">
</label>
<div class="btns">
<button id="startBtn">Run</button>
<button id="resetBtn" class="ghost">Reset</button>
</div>
</div>
<div class="panels">
<div class="orbit-panel">
<div class="panel-label">Orbit path</div>
<canvas id="orbitCanvas" width="260" height="260"></canvas>
</div>
<div class="energy-panel">
<div class="panel-label">Energy over time</div>
<canvas id="energyCanvas" width="260" height="130"></canvas>
<div class="legend">
<span class="dot euler"></span>Euler
<span class="dot verlet"></span>Verlet
</div>
</div>
</div>
<div class="status" id="status">Press Run to start the simulation.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.controls label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 110px; accent-color: #1d3557; }
.btns { display: flex; gap: .4rem; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.panels { display: flex; flex-wrap: wrap; gap: .8rem; }
.orbit-panel, .energy-panel { display: flex; flex-direction: column; gap: .2rem; }
.panel-label { font-size: .75rem; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #d0d7de; border-radius: 8px; background: #f8fafc; display: block; }
.legend { font-size: .8rem; margin-top: .3rem; display: flex; align-items: center; gap: .2rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.dot.euler { background: #e63946; }
.dot.verlet { background: #1d7f47; }
.status { font-size: .85rem; font-weight: 600; margin-top: .4rem; min-height: 1.2em; color: #444; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Orbit demo: compare Euler and Verlet (position form) for a 2-D inverse-square
// central force. F = -GM/r^2 directed toward origin; GM = 1.
// Euler: x += v*dt, v += a*dt (not symplectic → energy drifts up)
// Verlet: x_new = 2*x - x_prev + a*dt^2 (symplectic → shadow energy conserved)
const GM = 1.0;
const DT_TABLE = [0.005, 0.01, 0.02, 0.04, 0.06, 0.09, 0.13, 0.18];
// Shared initial conditions (circular orbit at radius r0).
const R0 = 0.5;
const V_CIRC = Math.sqrt(GM / R0); // speed for a perfect circular orbit
function accel(x, y) {
const r2 = x * x + y * y;
const r = Math.sqrt(r2);
const f = -GM / r2;
return [f * x / r, f * y / r];
}
function energy(x, y, vx, vy) {
const r = Math.sqrt(x * x + y * y);
return 0.5 * (vx * vx + vy * vy) - GM / r; // KE + PE
}
// State
let running = false;
let animId = null;
let dt = 0.04;
let step = 0;
// Euler state
let ex, ey, evx, evy;
// Verlet state (needs prev + current position; velocity derived for energy)
let vx_pos, vy_pos, vx_prev, vy_prev, vvx, vvy;
const HIST_MAX = 400; // energy history length
let eulerEnergy = [];
let verletEnergy = [];
let eulerTrail = [];
let verletTrail = [];
const TRAIL_MAX = 800;
function initState() {
// Euler: start at (R0, 0), velocity = (0, V_CIRC)
ex = R0; ey = 0; evx = 0; evy = V_CIRC;
// Verlet: bootstrap previous position using one backward Euler step
vx_pos = R0; vy_pos = 0;
vvx = 0; vvy = V_CIRC; // store velocity for energy calc
const [ax0, ay0] = accel(vx_pos, vy_pos);
// x_prev = x - v*dt + 0.5*a*dt^2 (from Taylor expansion)
vx_prev = vx_pos - vvx * dt + 0.5 * ax0 * dt * dt;
vy_prev = vy_pos - vvy * dt + 0.5 * ay0 * dt * dt;
eulerEnergy = [energy(ex, ey, evx, evy)];
verletEnergy = [energy(vx_pos, vy_pos, vvx, vvy)];
eulerTrail = [[ex, ey]];
verletTrail = [[vx_pos, vy_pos]];
step = 0;
}
function stepEuler() {
const [ax, ay] = accel(ex, ey);
ex += evx * dt;
ey += evy * dt;
evx += ax * dt;
evy += ay * dt;
}
function stepVerlet() {
const [ax, ay] = accel(vx_pos, vy_pos);
const nx = 2 * vx_pos - vx_prev + ax * dt * dt;
const ny = 2 * vy_pos - vy_prev + ay * dt * dt;
// Approximate velocity for energy (central difference)
vvx = (nx - vx_prev) / (2 * dt);
vvy = (ny - vy_prev) / (2 * dt);
vx_prev = vx_pos; vy_prev = vy_pos;
vx_pos = nx; vy_pos = ny;
}
// Canvas helpers
const orbitC = document.getElementById('orbitCanvas');
const energyC = document.getElementById('energyCanvas');
const octx = orbitC.getContext('2d');
const ectx = energyC.getContext('2d');
const OW = orbitC.width, OH = orbitC.height;
const EW = energyC.width, EH = energyC.height;
const SCALE = 110; // world units → pixels; center = (OW/2, OH/2)
function worldToCanvas(x, y) {
return [OW / 2 + x * SCALE, OH / 2 - y * SCALE];
}
function drawOrbit() {
octx.clearRect(0, 0, OW, OH);
// Center star
octx.beginPath();
octx.arc(OW / 2, OH / 2, 5, 0, Math.PI * 2);
octx.fillStyle = '#f4a261';
octx.fill();
function drawTrail(trail, color) {
if (trail.length < 2) return;
octx.beginPath();
const [x0, y0] = worldToCanvas(trail[0][0], trail[0][1]);
octx.moveTo(x0, y0);
for (let i = 1; i < trail.length; i++) {
const [xi, yi] = worldToCanvas(trail[i][0], trail[i][1]);
octx.lineTo(xi, yi);
}
octx.strokeStyle = color;
octx.lineWidth = 1.5;
octx.globalAlpha = 0.7;
octx.stroke();
octx.globalAlpha = 1;
}
drawTrail(eulerTrail, '#e63946');
drawTrail(verletTrail, '#1d7f47');
// Current positions
function drawDot(trail, color) {
if (!trail.length) return;
const [x, y] = worldToCanvas(trail[trail.length - 1][0], trail[trail.length - 1][1]);
octx.beginPath();
octx.arc(x, y, 5, 0, Math.PI * 2);
octx.fillStyle = color;
octx.fill();
}
drawDot(eulerTrail, '#e63946');
drawDot(verletTrail, '#1d7f47');
}
function drawEnergy() {
ectx.clearRect(0, 0, EW, EH);
const all = [...eulerEnergy, ...verletEnergy];
const eMin = Math.min(...all) - 0.05;
const eMax = Math.max(...all, eMin + 0.01) + 0.05;
const range = eMax - eMin;
function toECanvas(idx, e) {
const n = Math.max(eulerEnergy.length, verletEnergy.length);
const x = (idx / (HIST_MAX - 1)) * (EW - 20) + 10;
const y = EH - 10 - ((e - eMin) / range) * (EH - 20);
return [x, y];
}
function drawLine(arr, color) {
if (arr.length < 2) return;
ectx.beginPath();
const [x0, y0] = toECanvas(0, arr[0]);
ectx.moveTo(x0, y0);
for (let i = 1; i < arr.length; i++) {
const [xi, yi] = toECanvas(i, arr[i]);
ectx.lineTo(xi, yi);
}
ectx.strokeStyle = color;
ectx.lineWidth = 1.5;
ectx.stroke();
}
// Zero-energy line (approximate equilibrium)
const e0 = eulerEnergy[0];
const [, yz] = toECanvas(0, e0);
ectx.beginPath();
ectx.moveTo(10, yz); ectx.lineTo(EW - 10, yz);
ectx.strokeStyle = '#bbb';
ectx.lineWidth = 0.8;
ectx.setLineDash([4, 3]);
ectx.stroke();
ectx.setLineDash([]);
drawLine(eulerEnergy, '#e63946');
drawLine(verletEnergy, '#1d7f47');
}
function tick() {
const STEPS_PER_FRAME = 6;
for (let i = 0; i < STEPS_PER_FRAME; i++) {
stepEuler();
stepVerlet();
step++;
eulerTrail.push([ex, ey]);
if (eulerTrail.length > TRAIL_MAX) eulerTrail.shift();
verletTrail.push([vx_pos, vy_pos]);
if (verletTrail.length > TRAIL_MAX) verletTrail.shift();
if (step % 3 === 0) {
eulerEnergy.push(energy(ex, ey, evx, evy));
if (eulerEnergy.length > HIST_MAX) eulerEnergy.shift();
verletEnergy.push(energy(vx_pos, vy_pos, vvx, vvy));
if (verletEnergy.length > HIST_MAX) verletEnergy.shift();
}
}
drawOrbit();
drawEnergy();
// Report last energies
const ee = eulerEnergy[eulerEnergy.length - 1];
const ve = verletEnergy[verletEnergy.length - 1];
const e0 = eulerEnergy[0] || 1;
const drift = ((ee - e0) / Math.abs(e0) * 100).toFixed(1);
document.getElementById('status').textContent =
fmt('Step %step% | Euler E: %ee% (drift %drift%%) | Verlet E: %ve%', { step: step, ee: ee.toFixed(4), drift: drift, ve: ve.toFixed(4) });
if (running) animId = requestAnimationFrame(tick);
}
function reset() {
running = false;
if (animId) cancelAnimationFrame(animId);
animId = null;
dt = DT_TABLE[parseInt(document.getElementById('dtSlider').value) - 1];
initState();
drawOrbit();
drawEnergy();
document.getElementById('status').textContent = 'Press Run to start the simulation.';
document.getElementById('startBtn').textContent = 'Run';
}
document.getElementById('dtSlider').addEventListener('input', function () {
dt = DT_TABLE[parseInt(this.value) - 1];
document.getElementById('dtVal').textContent = dt.toFixed(3);
if (!running) {
initState();
drawOrbit();
drawEnergy();
}
});
document.getElementById('startBtn').addEventListener('click', function () {
if (running) {
running = false;
if (animId) cancelAnimationFrame(animId);
animId = null;
this.textContent = 'Run';
} else {
running = true;
this.textContent = 'Pause';
tick();
}
});
document.getElementById('resetBtn').addEventListener('click', reset);
// Init
reset();
Comments
Loading comments...