Physics Simulation
Heat Diffusion
Solve the 2D heat equation with an interactive finite difference solver. Paint hot/cold regions and watch thermal diffusion in real-time.
10
0.15
100
0
5
Min T
0°
Max T
0°
Avg T
0°
Iter
0
∂T/∂t = α·∇²T
Paint on the canvas to add heat or cold. Use the controls to adjust diffusivity and initial conditions. The simulation solves the heat equation numerically using an explicit finite difference scheme.
Paint on the canvas to add heat or cold. Use the controls to adjust diffusivity and initial conditions. The simulation solves the heat equation numerically using an explicit finite difference scheme.
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
const canvas = document.getElementById('heatCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
// Grid dimensions
const GRID_SIZE = 150;
const DX = 1.0; // spatial step
// State
let grid = new Float32Array(GRID_SIZE * GRID_SIZE);
let gridNext = new Float32Array(GRID_SIZE * GRID_SIZE);
let isPaused = false;
let isRunning = true;
let iterationCount = 0;
let activeBrush = 'heat';
let centerSourceActive = false;
let bottomSourceActive = false;
// Canvas interaction
let isMouseDown = false;
let lastX = 0, lastY = 0;
// Initialize
function init() {
resizeCanvas();
initializeGrid('hotspot');
animate();
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
}
function gridIndex(i, j) {
return i * GRID_SIZE + j;
}
function initializeGrid(shape) {
grid.fill(50); // Base cold temperature
const centerI = Math.floor(GRID_SIZE / 2);
const centerJ = Math.floor(GRID_SIZE / 2);
if (shape === 'hotspot') {
for (let i = 0; i < GRID_SIZE; i++) {
for (let j = 0; j < GRID_SIZE; j++) {
const di = i - centerI;
const dj = j - centerJ;
const dist = Math.sqrt(di * di + dj * dj);
const sigma = 20;
grid[gridIndex(i, j)] = 50 + 50 * Math.exp(-(dist * dist) / (2 * sigma * sigma));
}
}
} else if (shape === 'sides') {
for (let i = 0; i < GRID_SIZE; i++) {
for (let j = 0; j < GRID_SIZE; j++) {
grid[gridIndex(i, j)] = j < GRID_SIZE / 2 ? 100 : 0;
}
}
} else if (shape === 'checkerboard') {
const squareSize = 15;
for (let i = 0; i < GRID_SIZE; i++) {
for (let j = 0; j < GRID_SIZE; j++) {
const sq1 = Math.floor(i / squareSize);
const sq2 = Math.floor(j / squareSize);
grid[gridIndex(i, j)] = (sq1 + sq2) % 2 === 0 ? 100 : 0;
}
}
}
iterationCount = 0;
}
function setGridPoint(canvasX, canvasY, value) {
const rect = canvas.getBoundingClientRect();
const i = Math.floor((canvasY - rect.top) / rect.height * GRID_SIZE);
const j = Math.floor((canvasX - rect.left) / rect.width * GRID_SIZE);
const brushSize = parseInt(document.getElementById('brushSize').value);
for (let di = -brushSize; di <= brushSize; di++) {
for (let dj = -brushSize; dj <= brushSize; dj++) {
const dist = Math.sqrt(di * di + dj * dj);
if (dist <= brushSize) {
const ni = i + di;
const nj = j + dj;
if (ni >= 0 && ni < GRID_SIZE && nj >= 0 && nj < GRID_SIZE) {
const falloff = Math.max(0, 1 - dist / brushSize);
const idx = gridIndex(ni, nj);
grid[idx] = grid[idx] * (1 - falloff) + value * falloff;
}
}
}
}
}
function step() {
const alpha = parseFloat(document.getElementById('diffusivity').value);
const r = (alpha * 1.0) / (DX * DX); // stability factor
const boundary = document.getElementById('boundary').value;
// Stability check
if (r > 0.25) {
console.warn('Reducing iterations to maintain stability');
return;
}
for (let i = 1; i < GRID_SIZE - 1; i++) {
for (let j = 1; j < GRID_SIZE - 1; j++) {
const idx = gridIndex(i, j);
const idx_ip = gridIndex(i + 1, j);
const idx_im = gridIndex(i - 1, j);
const idx_jp = gridIndex(i, j + 1);
const idx_jm = gridIndex(i, j - 1);
const T_c = grid[idx];
const T_ip = grid[idx_ip];
const T_im = grid[idx_im];
const T_jp = grid[idx_jp];
const T_jm = grid[idx_jm];
const laplacian = T_ip + T_im + T_jp + T_jm - 4 * T_c;
gridNext[idx] = T_c + r * laplacian;
}
}
// Apply boundary conditions
if (boundary === 'fixed') {
// Keep edges at cold temperature
for (let i = 0; i < GRID_SIZE; i++) {
gridNext[gridIndex(i, 0)] = 0;
gridNext[gridIndex(i, GRID_SIZE - 1)] = 0;
gridNext[gridIndex(0, i)] = 0;
gridNext[gridIndex(GRID_SIZE - 1, i)] = 0;
}
} else if (boundary === 'insulated') {
// Copy edge values (insulated - no flux)
for (let i = 0; i < GRID_SIZE; i++) {
gridNext[gridIndex(i, 0)] = gridNext[gridIndex(i, 1)];
gridNext[gridIndex(i, GRID_SIZE - 1)] = gridNext[gridIndex(i, GRID_SIZE - 2)];
gridNext[gridIndex(0, i)] = gridNext[gridIndex(1, i)];
gridNext[gridIndex(GRID_SIZE - 1, i)] = gridNext[gridIndex(GRID_SIZE - 2, i)];
}
} else if (boundary === 'periodic') {
// Wrap edges
for (let i = 0; i < GRID_SIZE; i++) {
gridNext[gridIndex(i, 0)] = gridNext[gridIndex(i, GRID_SIZE - 2)];
gridNext[gridIndex(i, GRID_SIZE - 1)] = gridNext[gridIndex(i, 1)];
gridNext[gridIndex(0, i)] = gridNext[gridIndex(GRID_SIZE - 2, i)];
gridNext[gridIndex(GRID_SIZE - 1, i)] = gridNext[gridIndex(1, i)];
}
}
// Heat sources
if (centerSourceActive) {
const ci = Math.floor(GRID_SIZE / 2);
const cj = Math.floor(GRID_SIZE / 2);
gridNext[gridIndex(ci, cj)] = 100;
}
if (bottomSourceActive) {
const bottom = GRID_SIZE - 2;
for (let j = 5; j < GRID_SIZE - 5; j++) {
gridNext[gridIndex(bottom, j)] = 100;
}
}
// Swap grids
[grid, gridNext] = [gridNext, grid];
iterationCount++;
}
function getColorThermal(t) {
// Normalize to 0-1 range (0-100 scale)
const norm = Math.max(0, Math.min(1, t / 100));
if (norm < 0.2) {
// Deep blue to blue
const x = norm / 0.2;
return interpolateColor([0, 26, 77], [0, 102, 204], x);
} else if (norm < 0.4) {
// Blue to cyan
const x = (norm - 0.2) / 0.2;
return interpolateColor([0, 102, 204], [0, 204, 204], x);
} else if (norm < 0.6) {
// Cyan to yellow
const x = (norm - 0.4) / 0.2;
return interpolateColor([0, 204, 204], [245, 197, 24], x);
} else if (norm < 0.8) {
// Yellow to orange
const x = (norm - 0.6) / 0.2;
return interpolateColor([245, 197, 24], [255, 136, 0], x);
} else {
// Orange to red-white
const x = (norm - 0.8) / 0.2;
return interpolateColor([255, 136, 0], [255, 34, 0], x);
}
}
function getColorRainbow(t) {
const norm = Math.max(0, Math.min(1, t / 100));
const hue = norm * 360;
const rgb = hslToRgb(hue, 100, 50);
return rgb;
}
function getColorGrayscale(t) {
const v = Math.max(0, Math.min(255, (t / 100) * 255));
return [v, v, v];
}
function getColorInfrared(t) {
const norm = Math.max(0, Math.min(1, t / 100));
if (norm < 0.3) {
const x = norm / 0.3;
return interpolateColor([0, 0, 0], [0, 0, 255], x);
} else if (norm < 0.6) {
const x = (norm - 0.3) / 0.3;
return interpolateColor([0, 0, 255], [0, 255, 255], x);
} else if (norm < 0.8) {
const x = (norm - 0.6) / 0.2;
return interpolateColor([0, 255, 255], [255, 255, 0], x);
} else {
const x = (norm - 0.8) / 0.2;
return interpolateColor([255, 255, 0], [255, 0, 0], x);
}
}
function interpolateColor(c1, c2, t) {
return [
Math.round(c1[0] + (c2[0] - c1[0]) * t),
Math.round(c1[1] + (c2[1] - c1[1]) * t),
Math.round(c1[2] + (c2[2] - c1[2]) * t)
];
}
function hslToRgb(h, s, l) {
const c = ((100 - Math.abs(2 * l - 100)) / 100) * (s / 100);
const hh = h / 60;
const x = c * (1 - Math.abs((hh % 2) - 1));
let r = 0, g = 0, b = 0;
if (hh < 1) [r, g, b] = [c, x, 0];
else if (hh < 2) [r, g, b] = [x, c, 0];
else if (hh < 3) [r, g, b] = [0, c, x];
else if (hh < 4) [r, g, b] = [0, x, c];
else if (hh < 5) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const m = (l / 100) - (c / 2);
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255)
];
}
function render() {
const imageData = ctx.createImageData(GRID_SIZE, GRID_SIZE);
const data = imageData.data;
const colorScheme = document.getElementById('colorScheme').value;
let minT = Infinity, maxT = -Infinity, sumT = 0;
for (let i = 0; i < GRID_SIZE; i++) {
for (let j = 0; j < GRID_SIZE; j++) {
const idx = gridIndex(i, j);
const t = grid[idx];
minT = Math.min(minT, t);
maxT = Math.max(maxT, t);
sumT += t;
let rgb;
if (colorScheme === 'thermal') rgb = getColorThermal(t);
else if (colorScheme === 'rainbow') rgb = getColorRainbow(t);
else if (colorScheme === 'grayscale') rgb = getColorGrayscale(t);
else rgb = getColorInfrared(t);
const pixelIdx = (i * GRID_SIZE + j) * 4;
data[pixelIdx] = rgb[0];
data[pixelIdx + 1] = rgb[1];
data[pixelIdx + 2] = rgb[2];
data[pixelIdx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
// Update stats
const avgT = sumT / (GRID_SIZE * GRID_SIZE);
document.getElementById('minTemp').textContent = Math.round(minT) + '°';
document.getElementById('maxTemp').textContent = Math.round(maxT) + '°';
document.getElementById('avgTemp').textContent = Math.round(avgT) + '°';
document.getElementById('iterations').textContent = iterationCount;
}
function animate() {
if (!isPaused) {
const iterations = parseInt(document.getElementById('speed').value);
for (let k = 0; k < iterations; k++) {
step();
}
}
render();
requestAnimationFrame(animate);
}
// Event listeners
document.getElementById('brushSize').addEventListener('input', (e) => {
document.getElementById('brushSizeValue').textContent = e.target.value;
});
document.getElementById('diffusivity').addEventListener('input', (e) => {
document.getElementById('diffusivityValue').textContent = parseFloat(e.target.value).toFixed(2);
});
document.getElementById('speed').addEventListener('input', (e) => {
document.getElementById('speedValue').textContent = e.target.value;
});
document.querySelectorAll('.brush-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.brush-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeBrush = btn.dataset.brush;
});
});
document.getElementById('pauseBtn').addEventListener('click', function() {
isPaused = !isPaused;
this.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
this.classList.toggle('active', isPaused);
});
document.getElementById('resetBtn').addEventListener('click', () => {
const shape = document.getElementById('initialShape').value;
initializeGrid(shape);
});
document.getElementById('clearBtn').addEventListener('click', () => {
grid.fill(50);
iterationCount = 0;
});
document.getElementById('initialShape').addEventListener('change', (e) => {
if (e.target.value !== 'custom') {
initializeGrid(e.target.value);
}
});
document.getElementById('centerSourceBtn').addEventListener('click', function() {
centerSourceActive = !centerSourceActive;
this.classList.toggle('active', centerSourceActive);
});
document.getElementById('bottomSourceBtn').addEventListener('click', function() {
bottomSourceActive = !bottomSourceActive;
this.classList.toggle('active', bottomSourceActive);
});
// Canvas interaction
canvas.addEventListener('mousedown', (e) => {
isMouseDown = true;
const value = activeBrush === 'heat' ? 100 : 0;
setGridPoint(e.clientX, e.clientY, value);
});
canvas.addEventListener('mousemove', (e) => {
if (isMouseDown) {
const value = activeBrush === 'heat' ? 100 : 0;
setGridPoint(e.clientX, e.clientY, value);
}
});
canvas.addEventListener('mouseup', () => {
isMouseDown = false;
});
canvas.addEventListener('mouseleave', () => {
isMouseDown = false;
});
// Touch support
canvas.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
isMouseDown = true;
const value = activeBrush === 'heat' ? 100 : 0;
setGridPoint(touch.clientX, touch.clientY, value);
});
canvas.addEventListener('touchmove', (e) => {
if (isMouseDown) {
const touch = e.touches[0];
const value = activeBrush === 'heat' ? 100 : 0;
setGridPoint(touch.clientX, touch.clientY, value);
}
});
canvas.addEventListener('touchend', () => {
isMouseDown = false;
});
// Responsive canvas
window.addEventListener('resize', resizeCanvas);
// Initialize
init();