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
Max T
Avg T
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.
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();