The 1-D heat equation asks: given an initial temperature profile along a thin bar, how does heat spread over time? The exact answer involves an infinite series of sines. The finite difference answer is a loop.
<div class="controls">
<label>Grid points (N): <strong id="nVal">30</strong>
<input type="range" id="nSlider" min="10" max="60" value="30" step="5">
</label>
<label>Stability r: <strong id="rVal">0.40</strong>
<input type="range" id="rSlider" min="0.05" max="0.60" value="0.40" step="0.05">
</label>
<div class="btn-row">
<button id="btnStep" type="button">Step</button>
<button id="btnRun" type="button">Run</button>
<button id="btnStop" type="button" disabled>Stop</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div id="info" class="info">Step 0 — r = 0.40 (stable)</div>
</div>
<canvas id="chart" width="460" height="220"></canvas>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .6rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label strong { min-width: 2.8rem; }
input[type=range] { flex: 1; min-width: 120px; cursor: pointer; }
.btn-row { display: flex; gap: .45rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border-radius: 7px;
border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.info { font-size: .82rem; color: #555; min-height: 1.2em; }
canvas { border: 1px solid #dde3ea; border-radius: 8px; width: 100%; max-width: 460px;
display: block; background: #f9fbfc; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Explicit finite difference scheme for the 1-D heat equation:
// u_t = alpha * u_xx
// Discretised: u[i]^{n+1} = u[i]^n + r*(u[i+1]^n - 2*u[i]^n + u[i-1]^n)
// where r = alpha*dt/dx^2. Stable when r <= 0.5.
// Boundary conditions: u[0] = u[N-1] = 0 (bar ends held at 0 °C).
// Initial condition: triangular spike — rises to 1.0 at the midpoint.
// Exact solution: Fourier sine series (first 20 terms shown in blue).
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const CW = canvas.width, CH = canvas.height;
const PAD = { top: 18, right: 14, bottom: 30, left: 38 };
const PW = CW - PAD.left - PAD.right;
const PH = CH - PAD.top - PAD.bottom;
// --- Parameters (updated from sliders) ---
let N = 30; // number of grid points (including endpoints)
let r = 0.40; // stability parameter alpha*dt/dx^2
// --- State ---
let u = []; // current temperature array
let step = 0;
let timer = null;
// --- Initial condition: triangular spike ---
function init() {
u = new Array(N).fill(0);
const mid = Math.floor((N - 1) / 2);
for (let i = 1; i < N - 1; i++) {
u[i] = i <= mid ? i / mid : (N - 1 - i) / (N - 1 - mid);
}
step = 0;
}
// --- One explicit FD step ---
function fdStep() {
const next = u.slice();
for (let i = 1; i < N - 1; i++) {
next[i] = u[i] + r * (u[i + 1] - 2 * u[i] + u[i - 1]);
}
next[0] = 0; next[N - 1] = 0;
u = next;
step++;
}
// --- Exact solution: Fourier sine series (first 20 terms) ---
// For the triangular initial condition on [0,1], the exact Fourier coefficients
// of the odd extension are: b_n = (8/(n^2 * pi^2)) * sin(n*pi/2) for n odd, 0 for n even.
// (This is the standard Fourier sine series of a triangle on [0,1] with peak at 0.5.)
function exactU(t) {
const ex = new Array(N).fill(0);
const TERMS = 20;
for (let i = 1; i < N - 1; i++) {
const x = i / (N - 1); // x in (0,1)
let val = 0;
for (let n = 1; n <= TERMS; n++) {
// b_n for triangle on [0,1] with peak at 0.5:
const bn = (8 / (n * n * Math.PI * Math.PI)) * Math.sin(n * Math.PI / 2);
val += bn * Math.exp(-n * n * Math.PI * Math.PI * t) * Math.sin(n * Math.PI * x);
}
ex[i] = Math.max(0, val);
}
return ex;
}
// --- Drawing ---
function draw() {
ctx.clearRect(0, 0, CW, CH);
// Background
ctx.fillStyle = '#f9fbfc';
ctx.fillRect(0, 0, CW, CH);
// Axes
ctx.strokeStyle = '#b0bec8';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, PAD.top + PH);
ctx.lineTo(PAD.left + PW, PAD.top + PH);
ctx.stroke();
// Y axis labels
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
for (let v of [0, 0.5, 1.0]) {
const py = PAD.top + PH - v * PH;
ctx.fillText(v.toFixed(1), PAD.left - 4, py + 4);
ctx.strokeStyle = '#e3e8ed';
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(PAD.left, py);
ctx.lineTo(PAD.left + PW, py);
ctx.stroke();
ctx.setLineDash([]);
}
// X axis labels
ctx.textAlign = 'center';
ctx.fillText('0', PAD.left, PAD.top + PH + 16);
ctx.fillText('½', PAD.left + PW / 2, PAD.top + PH + 16);
ctx.fillText('1', PAD.left + PW, PAD.top + PH + 16);
// Axis labels
ctx.fillStyle = '#555';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText('Temperature', PAD.left + 2, PAD.top - 4);
ctx.textAlign = 'center';
ctx.fillText('Position along bar', PAD.left + PW / 2, CH - 2);
// Compute t (dimensionless time = step * r / (N-1)^2 * (N-1)^2 * ...)
// In our scheme: t_physical = step * dt. We set dx = 1/(N-1), so
// r = alpha*dt/dx^2 => dt = r*dx^2/alpha. With alpha=1 and dx=1/(N-1):
const t = step * r / ((N - 1) * (N - 1));
// Draw exact solution (blue)
const ex = exactU(t);
ctx.strokeStyle = '#1a6fc4';
ctx.lineWidth = 1.8;
ctx.setLineDash([5, 3]);
ctx.beginPath();
for (let i = 0; i < N; i++) {
const px = PAD.left + (i / (N - 1)) * PW;
const py = PAD.top + PH - ex[i] * PH;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
// Draw FD approximation (orange/red)
ctx.strokeStyle = '#e05d2a';
ctx.lineWidth = 2.2;
ctx.beginPath();
for (let i = 0; i < N; i++) {
const px = PAD.left + (i / (N - 1)) * PW;
const py = PAD.top + PH - Math.max(0, Math.min(1, u[i])) * PH;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
// Dots for FD
ctx.fillStyle = '#e05d2a';
for (let i = 0; i < N; i++) {
const px = PAD.left + (i / (N - 1)) * PW;
const py = PAD.top + PH - Math.max(0, Math.min(1, u[i])) * PH;
ctx.beginPath();
ctx.arc(px, py, 2.4, 0, 2 * Math.PI);
ctx.fill();
}
// Legend
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillStyle = '#e05d2a';
ctx.fillText('▬ FD grid', PAD.left + 4, PAD.top + 14);
ctx.fillStyle = '#1a6fc4';
ctx.fillText('╌ Exact', PAD.left + 78, PAD.top + 14);
// Info
const stable = r <= 0.5;
const infoEl = document.getElementById('info');
infoEl.textContent = fmt('Step %step% — r = %r% (%v%)', { step: step, r: r.toFixed(2), v: stable ? 'stable ✓' : 'UNSTABLE ✗ — expect blow-up!' });
infoEl.style.color = stable ? '#555' : '#c92f3c';
}
// --- Controls ---
const nSlider = document.getElementById('nSlider');
const rSlider = document.getElementById('rSlider');
nSlider.addEventListener('input', () => {
N = +nSlider.value;
document.getElementById('nVal').textContent = N;
stopRun(); init(); draw();
});
rSlider.addEventListener('input', () => {
r = +rSlider.value;
document.getElementById('rVal').textContent = r.toFixed(2);
draw();
});
document.getElementById('btnStep').addEventListener('click', () => {
fdStep(); draw();
});
document.getElementById('btnRun').addEventListener('click', startRun);
document.getElementById('btnStop').addEventListener('click', stopRun);
document.getElementById('btnReset').addEventListener('click', () => {
stopRun(); init(); draw();
});
function startRun() {
if (timer) return;
document.getElementById('btnRun').disabled = true;
document.getElementById('btnStop').disabled = false;
timer = setInterval(() => {
for (let k = 0; k < 3; k++) fdStep();
draw();
}, 50);
}
function stopRun() {
clearInterval(timer); timer = null;
document.getElementById('btnRun').disabled = false;
document.getElementById('btnStop').disabled = true;
}
// --- Boot ---
init();
draw();
<div class="controls">
<label>Grid points (N): <strong id="nVal">30</strong>
<input type="range" id="nSlider" min="10" max="60" value="30" step="5">
</label>
<label>Stability r: <strong id="rVal">0.40</strong>
<input type="range" id="rSlider" min="0.05" max="0.60" value="0.40" step="0.05">
</label>
<div class="btn-row">
<button id="btnStep" type="button">Step</button>
<button id="btnRun" type="button">Run</button>
<button id="btnStop" type="button" disabled>Stop</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div id="info" class="info">Step 0 — r = 0.40 (stable)</div>
</div>
<canvas id="chart" width="460" height="220"></canvas>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .6rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label strong { min-width: 2.8rem; }
input[type=range] { flex: 1; min-width: 120px; cursor: pointer; }
.btn-row { display: flex; gap: .45rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border-radius: 7px;
border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.info { font-size: .82rem; color: #555; min-height: 1.2em; }
canvas { border: 1px solid #dde3ea; border-radius: 8px; width: 100%; max-width: 460px;
display: block; background: #f9fbfc; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Explicit finite difference scheme for the 1-D heat equation:
// u_t = alpha * u_xx
// Discretised: u[i]^{n+1} = u[i]^n + r*(u[i+1]^n - 2*u[i]^n + u[i-1]^n)
// where r = alpha*dt/dx^2. Stable when r <= 0.5.
// Boundary conditions: u[0] = u[N-1] = 0 (bar ends held at 0 °C).
// Initial condition: triangular spike — rises to 1.0 at the midpoint.
// Exact solution: Fourier sine series (first 20 terms shown in blue).
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const CW = canvas.width, CH = canvas.height;
const PAD = { top: 18, right: 14, bottom: 30, left: 38 };
const PW = CW - PAD.left - PAD.right;
const PH = CH - PAD.top - PAD.bottom;
// --- Parameters (updated from sliders) ---
let N = 30; // number of grid points (including endpoints)
let r = 0.40; // stability parameter alpha*dt/dx^2
// --- State ---
let u = []; // current temperature array
let step = 0;
let timer = null;
// --- Initial condition: triangular spike ---
function init() {
u = new Array(N).fill(0);
const mid = Math.floor((N - 1) / 2);
for (let i = 1; i < N - 1; i++) {
u[i] = i <= mid ? i / mid : (N - 1 - i) / (N - 1 - mid);
}
step = 0;
}
// --- One explicit FD step ---
function fdStep() {
const next = u.slice();
for (let i = 1; i < N - 1; i++) {
next[i] = u[i] + r * (u[i + 1] - 2 * u[i] + u[i - 1]);
}
next[0] = 0; next[N - 1] = 0;
u = next;
step++;
}
// --- Exact solution: Fourier sine series (first 20 terms) ---
// For the triangular initial condition on [0,1], the exact Fourier coefficients
// of the odd extension are: b_n = (8/(n^2 * pi^2)) * sin(n*pi/2) for n odd, 0 for n even.
// (This is the standard Fourier sine series of a triangle on [0,1] with peak at 0.5.)
function exactU(t) {
const ex = new Array(N).fill(0);
const TERMS = 20;
for (let i = 1; i < N - 1; i++) {
const x = i / (N - 1); // x in (0,1)
let val = 0;
for (let n = 1; n <= TERMS; n++) {
// b_n for triangle on [0,1] with peak at 0.5:
const bn = (8 / (n * n * Math.PI * Math.PI)) * Math.sin(n * Math.PI / 2);
val += bn * Math.exp(-n * n * Math.PI * Math.PI * t) * Math.sin(n * Math.PI * x);
}
ex[i] = Math.max(0, val);
}
return ex;
}
// --- Drawing ---
function draw() {
ctx.clearRect(0, 0, CW, CH);
// Background
ctx.fillStyle = '#f9fbfc';
ctx.fillRect(0, 0, CW, CH);
// Axes
ctx.strokeStyle = '#b0bec8';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, PAD.top);
ctx.lineTo(PAD.left, PAD.top + PH);
ctx.lineTo(PAD.left + PW, PAD.top + PH);
ctx.stroke();
// Y axis labels
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
for (let v of [0, 0.5, 1.0]) {
const py = PAD.top + PH - v * PH;
ctx.fillText(v.toFixed(1), PAD.left - 4, py + 4);
ctx.strokeStyle = '#e3e8ed';
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(PAD.left, py);
ctx.lineTo(PAD.left + PW, py);
ctx.stroke();
ctx.setLineDash([]);
}
// X axis labels
ctx.textAlign = 'center';
ctx.fillText('0', PAD.left, PAD.top + PH + 16);
ctx.fillText('½', PAD.left + PW / 2, PAD.top + PH + 16);
ctx.fillText('1', PAD.left + PW, PAD.top + PH + 16);
// Axis labels
ctx.fillStyle = '#555';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText('Temperature', PAD.left + 2, PAD.top - 4);
ctx.textAlign = 'center';
ctx.fillText('Position along bar', PAD.left + PW / 2, CH - 2);
// Compute t (dimensionless time = step * r / (N-1)^2 * (N-1)^2 * ...)
// In our scheme: t_physical = step * dt. We set dx = 1/(N-1), so
// r = alpha*dt/dx^2 => dt = r*dx^2/alpha. With alpha=1 and dx=1/(N-1):
const t = step * r / ((N - 1) * (N - 1));
// Draw exact solution (blue)
const ex = exactU(t);
ctx.strokeStyle = '#1a6fc4';
ctx.lineWidth = 1.8;
ctx.setLineDash([5, 3]);
ctx.beginPath();
for (let i = 0; i < N; i++) {
const px = PAD.left + (i / (N - 1)) * PW;
const py = PAD.top + PH - ex[i] * PH;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
// Draw FD approximation (orange/red)
ctx.strokeStyle = '#e05d2a';
ctx.lineWidth = 2.2;
ctx.beginPath();
for (let i = 0; i < N; i++) {
const px = PAD.left + (i / (N - 1)) * PW;
const py = PAD.top + PH - Math.max(0, Math.min(1, u[i])) * PH;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
// Dots for FD
ctx.fillStyle = '#e05d2a';
for (let i = 0; i < N; i++) {
const px = PAD.left + (i / (N - 1)) * PW;
const py = PAD.top + PH - Math.max(0, Math.min(1, u[i])) * PH;
ctx.beginPath();
ctx.arc(px, py, 2.4, 0, 2 * Math.PI);
ctx.fill();
}
// Legend
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillStyle = '#e05d2a';
ctx.fillText('▬ FD grid', PAD.left + 4, PAD.top + 14);
ctx.fillStyle = '#1a6fc4';
ctx.fillText('╌ Exact', PAD.left + 78, PAD.top + 14);
// Info
const stable = r <= 0.5;
const infoEl = document.getElementById('info');
infoEl.textContent = fmt('Step %step% — r = %r% (%v%)', { step: step, r: r.toFixed(2), v: stable ? 'stable ✓' : 'UNSTABLE ✗ — expect blow-up!' });
infoEl.style.color = stable ? '#555' : '#c92f3c';
}
// --- Controls ---
const nSlider = document.getElementById('nSlider');
const rSlider = document.getElementById('rSlider');
nSlider.addEventListener('input', () => {
N = +nSlider.value;
document.getElementById('nVal').textContent = N;
stopRun(); init(); draw();
});
rSlider.addEventListener('input', () => {
r = +rSlider.value;
document.getElementById('rVal').textContent = r.toFixed(2);
draw();
});
document.getElementById('btnStep').addEventListener('click', () => {
fdStep(); draw();
});
document.getElementById('btnRun').addEventListener('click', startRun);
document.getElementById('btnStop').addEventListener('click', stopRun);
document.getElementById('btnReset').addEventListener('click', () => {
stopRun(); init(); draw();
});
function startRun() {
if (timer) return;
document.getElementById('btnRun').disabled = true;
document.getElementById('btnStop').disabled = false;
timer = setInterval(() => {
for (let k = 0; k < 3; k++) fdStep();
draw();
}, 50);
}
function stopRun() {
clearInterval(timer); timer = null;
document.getElementById('btnRun').disabled = false;
document.getElementById('btnStop').disabled = true;
}
// --- Boot ---
init();
draw();
The bar is divided into N grid points. At each time step the temperature at an interior point is updated by the rule:
ui←ui+r(ui+1−2ui+ui−1)
where r=αdt/dx2 is the stability parameter. Keep r≤0.5 and the scheme is stable; push r above 0.5 and errors explode. Try the Step button to advance one tick, or Run to watch diffusion unfold in real time. The exact solution (computed with the first 20 sine terms) is drawn in blue for comparison — notice how the grid approximation tracks it closely but not perfectly.
Comments
Loading comments...