Physics Simulation

Ripple Tank

Explore 2D wave interference, superposition, and diffraction patterns. Drag sources, adjust frequencies, and watch constructive and destructive interference in real-time.

Drag source dots to move them. Use controls below to adjust wave properties.

Global

200 px/s
1.0×
30%

Sources (2)

Wave Formula

Height at (x,y):
h(x,y,t) = Σ Aᵢ·sin(2πfᵢ·t − k·r + φᵢ)·exp(−r/d)
• r = distance from source
• k = 2πf/c (wave number)
• φ = phase offset
Interference:
Constructive: Δr = mλ
Destructive: Δr = (m+½)λ

λ = c/f (wavelength)
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// Canvas and simulation setup
    const canvas = document.getElementById('rippleCanvas');
    const ctx = canvas.getContext('2d');
    const intensityCanvas = document.getElementById('intensityCanvas');
    const intensityCtx = intensityCanvas.getContext('2d');

    const GRID_WIDTH = 200;
    const GRID_HEIGHT = 200;
    const CANVAS_SIZE = 600;

    // Simulation state
    let sources = [
      { x: 100, y: 100, freq: 2.0, amp: 1.0, phase: 0, active: true },
      { x: 300, y: 100, freq: 2.0, amp: 1.0, phase: 0, active: true }
    ];

    let time = 0;
    let simSpeed = 1;
    let waveSpeed = 200; // px/s
    let decay = 30;
    let colorMode = 'blue-amber';
    let isPlaying = true;
    let showSources = false;
    let showIntensity = false;
    let lastFrameTime = Date.now();

    // Grid for wave heights
    let grid = new Array(GRID_WIDTH * GRID_HEIGHT);
    let intensityData = new Array(GRID_WIDTH);

    // Dragging
    let draggingSource = null;
    const dragRadius = 20;

    // ===== Wave Computation =====
    function computeHeight(x, y, t, sources) {
      let height = 0;
      const c = waveSpeed / 100; // Wave speed in grid units
      const decayFactor = decay / 100;

      for (let source of sources) {
        if (!source.active) continue;

        const dx = x - source.x;
        const dy = y - source.y;
        const r = Math.sqrt(dx * dx + dy * dy);

        if (r < 0.1) {
          // At source location
          height += source.amp * Math.sin(2 * Math.PI * source.freq * t + source.phase);
        } else {
          // Away from source
          const k = (2 * Math.PI * source.freq) / c;
          const wave = source.amp * Math.sin(2 * Math.PI * source.freq * t - k * r + source.phase);
          const damping = Math.exp(-r * decayFactor / 100);
          height += wave * damping;
        }
      }

      return Math.max(-2, Math.min(2, height)); // Clamp
    }

    function updateGrid() {
      for (let gy = 0; gy < GRID_HEIGHT; gy++) {
        for (let gx = 0; gx < GRID_WIDTH; gx++) {
          const idx = gy * GRID_WIDTH + gx;
          grid[idx] = computeHeight(gx, gy, time, sources);
        }
      }

      // Compute intensity along center line for intensity display
      if (showIntensity) {
        const centerY = GRID_HEIGHT / 2;
        for (let gx = 0; gx < GRID_WIDTH; gx++) {
          let intensity = 0;
          for (let source of sources) {
            if (!source.active) continue;
            const dx = gx - source.x;
            const dy = centerY - source.y;
            const r = Math.sqrt(dx * dx + dy * dy);
            const c = waveSpeed / 100;
            const k = (2 * Math.PI * source.freq) / c;
            const wave = source.amp * Math.sin(2 * Math.PI * source.freq * time - k * r + source.phase);
            const damping = Math.exp(-r * (decay / 100) / 100);
            intensity += wave * damping;
          }
          intensityData[gx] = Math.max(-2, Math.min(2, intensity));
        }
      }
    }

    // ===== Color Mapping =====
    function mapHeight(h) {
      // Normalize h from [-2, 2] to [0, 1]
      const normalized = (h + 2) / 4;
      const clamped = Math.max(0, Math.min(1, normalized));

      if (colorMode === 'blue-amber') {
        if (clamped < 0.5) {
          // Dark blue → black
          const t = clamped * 2;
          const r = Math.round((0x00 + (0x1a - 0x00) * t) * (1 - t * 0.5));
          const g = Math.round((0x1a + (0x4d - 0x1a) * t) * (1 - t * 0.5));
          const b = Math.round((0x4d + (0x99 - 0x4d) * t) * (1 - t * 0.5));
          return `rgb(${r},${g},${b})`;
        } else {
          // Amber → white
          const t = (clamped - 0.5) * 2;
          const r = Math.round(0xff8800 + (0xff - 0x88) * 256 * t);
          const g = Math.round(0x88 + (0xff - 0x88) * t);
          const b = Math.round(0x00 + 0xe7 * t);
          return `rgb(${(0xff8800 >> 16) & 0xff + ((0xff - 0x88) * t)}, ${0x88 + (0xff - 0x88) * t}, ${0xe7 * t})`;
        }
      } else if (colorMode === 'rainbow') {
        const hue = clamped * 360;
        const saturation = 100;
        const lightness = 40 + clamped * 20;
        return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
      } else if (colorMode === 'grayscale') {
        const gray = Math.round(clamped * 255);
        return `rgb(${gray}, ${gray}, ${gray})`;
      }
    }

    function heightToColor(h) {
      const normalized = (h + 2) / 4;
      const clamped = Math.max(0, Math.min(1, normalized));

      if (colorMode === 'blue-amber') {
        if (clamped < 0.5) {
          const t = clamped * 2;
          const r = Math.round(0x00 * (1 - t) + 0x1a * t);
          const g = Math.round(0x1a * (1 - t) + 0x4d * t);
          const b = Math.round(0x4d * (1 - t) + 0x99 * t);
          return (0xff << 24) | (r << 16) | (g << 8) | b;
        } else {
          const t = (clamped - 0.5) * 2;
          const r = Math.round(0xff8800 * (1 - t) + 0xff * t);
          const g = Math.round(0x88 * (1 - t) + 0xff * t);
          const b = Math.round(0x00 * (1 - t) + 0xe7 * t);
          return (0xff << 24) | (r << 16) | (g << 8) | b;
        }
      } else if (colorMode === 'rainbow') {
        const hue = clamped * 360;
        const r = Math.round(Math.abs(Math.sin(hue * Math.PI / 180)) * 255);
        const g = Math.round(Math.abs(Math.sin((hue + 120) * Math.PI / 180)) * 255);
        const b = Math.round(Math.abs(Math.sin((hue + 240) * Math.PI / 180)) * 255);
        return (0xff << 24) | (r << 16) | (g << 8) | b;
      } else if (colorMode === 'grayscale') {
        const gray = Math.round(clamped * 255);
        return (0xff << 24) | (gray << 16) | (gray << 8) | gray;
      }
    }

    // ===== Rendering =====
    function render() {
      // Create image data
      const imageData = ctx.createImageData(GRID_WIDTH, GRID_HEIGHT);
      const data = imageData.data;

      for (let i = 0; i < grid.length; i++) {
        const color = heightToColor(grid[i]);
        const idx = i * 4;
        data[idx] = (color >> 16) & 0xff;
        data[idx + 1] = (color >> 8) & 0xff;
        data[idx + 2] = color & 0xff;
        data[idx + 3] = 0xff;
      }

      ctx.putImageData(imageData, 0, 0);

      // Scale up to canvas
      ctx.imageSmoothingEnabled = true;
      ctx.drawImage(canvas, 0, 0, GRID_WIDTH, GRID_HEIGHT, 0, 0, CANVAS_SIZE, CANVAS_SIZE);

      // Draw sources if enabled
      if (showSources) {
        for (let source of sources) {
          if (!source.active) continue;
          const sx = (source.x / GRID_WIDTH) * CANVAS_SIZE;
          const sy = (source.y / GRID_HEIGHT) * CANVAS_SIZE;

          ctx.fillStyle = source.active ? '#00ff00' : '#ff0000';
          ctx.beginPath();
          ctx.arc(sx, sy, 8, 0, Math.PI * 2);
          ctx.fill();

          ctx.strokeStyle = '#ffffff';
          ctx.lineWidth = 1;
          ctx.stroke();
        }
      }
    }

    function renderIntensity() {
      if (!showIntensity) return;

      const imageData = intensityCtx.createImageData(GRID_WIDTH, 100);
      const data = imageData.data;

      for (let i = 0; i < GRID_WIDTH; i++) {
        const intensity = intensityData[i];
        const normalized = (intensity + 2) / 4;
        const clamped = Math.max(0, Math.min(1, normalized));

        let r, g, b;
        if (colorMode === 'blue-amber') {
          if (clamped < 0.5) {
            const t = clamped * 2;
            r = Math.round(0x00 * (1 - t) + 0x1a * t);
            g = Math.round(0x1a * (1 - t) + 0x4d * t);
            b = Math.round(0x4d * (1 - t) + 0x99 * t);
          } else {
            const t = (clamped - 0.5) * 2;
            r = Math.round(0xff * (1 - t) + 0xff * t);
            g = Math.round(0x88 * (1 - t) + 0xff * t);
            b = Math.round(0x00 * (1 - t) + 0xe7 * t);
          }
        } else if (colorMode === 'rainbow') {
          const hue = clamped * 360;
          r = Math.round(Math.abs(Math.sin(hue * Math.PI / 180)) * 255);
          g = Math.round(Math.abs(Math.sin((hue + 120) * Math.PI / 180)) * 255);
          b = Math.round(Math.abs(Math.sin((hue + 240) * Math.PI / 180)) * 255);
        } else {
          const gray = Math.round(clamped * 255);
          r = g = b = gray;
        }

        for (let y = 0; y < 100; y++) {
          const idx = (y * GRID_WIDTH + i) * 4;
          data[idx] = r;
          data[idx + 1] = g;
          data[idx + 2] = b;
          data[idx + 3] = 0xff;
        }
      }

      intensityCtx.putImageData(imageData, 0, 0);
    }

    // ===== Animation Loop =====
    function animate() {
      const now = Date.now();
      const deltaTime = (now - lastFrameTime) / 1000;
      lastFrameTime = now;

      if (isPlaying) {
        time += deltaTime * simSpeed;
      }

      updateGrid();
      render();
      renderIntensity();

      requestAnimationFrame(animate);
    }

    // ===== UI Updates =====
    function updateSourceUI() {
      document.getElementById('sourceCount').textContent = sources.length;
      const list = document.getElementById('sourcesList');
      list.innerHTML = '';

      sources.forEach((source, idx) => {
        const panel = document.createElement('div');
        panel.className = 'source-panel';

        const header = document.createElement('h4');
        const label = document.createElement('span');
        label.textContent = `Source ${idx + 1}`;
        const toggle = document.createElement('div');
        toggle.className = `source-toggle ${source.active ? 'active' : ''}`;
        toggle.onclick = (e) => {
          source.active = !source.active;
          toggle.classList.toggle('active');
          e.stopPropagation();
        };

        header.appendChild(label);
        header.appendChild(toggle);
        panel.appendChild(header);

        const sliders = document.createElement('div');
        sliders.className = 'source-sliders';

        // Frequency
        const freqItem = document.createElement('div');
        freqItem.className = 'source-slider-item';
        const freqLabel = document.createElement('label');
        freqLabel.textContent = 'Frequency (Hz)';
        const freqInput = document.createElement('input');
        freqInput.type = 'range';
        freqInput.min = '0.5';
        freqInput.max = '5';
        freqInput.step = '0.1';
        freqInput.value = source.freq;
        freqInput.onchange = (e) => {
          source.freq = parseFloat(e.target.value);
          freqValue.textContent = source.freq.toFixed(1);
        };
        const freqValue = document.createElement('div');
        freqValue.className = 'source-value';
        freqValue.textContent = source.freq.toFixed(1);
        freqItem.appendChild(freqLabel);
        freqItem.appendChild(freqInput);
        freqItem.appendChild(freqValue);
        sliders.appendChild(freqItem);

        // Amplitude
        const ampItem = document.createElement('div');
        ampItem.className = 'source-slider-item';
        const ampLabel = document.createElement('label');
        ampLabel.textContent = 'Amplitude';
        const ampInput = document.createElement('input');
        ampInput.type = 'range';
        ampInput.min = '0.1';
        ampInput.max = '2';
        ampInput.step = '0.1';
        ampInput.value = source.amp;
        ampInput.onchange = (e) => {
          source.amp = parseFloat(e.target.value);
          ampValue.textContent = source.amp.toFixed(1);
        };
        const ampValue = document.createElement('div');
        ampValue.className = 'source-value';
        ampValue.textContent = source.amp.toFixed(1);
        ampItem.appendChild(ampLabel);
        ampItem.appendChild(ampInput);
        ampItem.appendChild(ampValue);
        sliders.appendChild(ampItem);

        // Phase
        const phaseItem = document.createElement('div');
        phaseItem.className = 'source-slider-item';
        const phaseLabel = document.createElement('label');
        phaseLabel.textContent = 'Phase (°)';
        const phaseInput = document.createElement('input');
        phaseInput.type = 'range';
        phaseInput.min = '0';
        phaseInput.max = '360';
        phaseInput.step = '5';
        phaseInput.value = (source.phase * 180) / Math.PI;
        phaseInput.onchange = (e) => {
          source.phase = (parseFloat(e.target.value) * Math.PI) / 180;
          phaseValue.textContent = Math.round(parseFloat(e.target.value));
        };
        const phaseValue = document.createElement('div');
        phaseValue.className = 'source-value';
        phaseValue.textContent = Math.round((source.phase * 180) / Math.PI);
        phaseItem.appendChild(phaseLabel);
        phaseItem.appendChild(phaseInput);
        phaseItem.appendChild(phaseValue);
        sliders.appendChild(phaseItem);

        // Delete button
        if (sources.length > 1) {
          const delBtn = document.createElement('button');
          delBtn.className = 'btn-ghost';
          delBtn.textContent = '✕ Remove';
          delBtn.style.marginTop = '8px';
          delBtn.onclick = () => {
            sources.splice(idx, 1);
            updateSourceUI();
          };
          sliders.appendChild(delBtn);
        }

        panel.appendChild(sliders);
        list.appendChild(panel);
      });
    }

    // ===== Event Listeners =====
    document.getElementById('simSpeed').addEventListener('input', (e) => {
      simSpeed = parseFloat(e.target.value);
      document.getElementById('simSpeedDisplay').textContent = simSpeed.toFixed(1);
    });

    document.getElementById('waveSpeed').addEventListener('input', (e) => {
      waveSpeed = parseFloat(e.target.value);
      document.getElementById('waveSpeeddisplay').textContent = waveSpeed;
    });

    document.getElementById('decay').addEventListener('input', (e) => {
      decay = parseFloat(e.target.value);
      document.getElementById('decayDisplay').textContent = decay;
    });

    document.getElementById('playPauseBtn').addEventListener('click', (e) => {
      isPlaying = !isPlaying;
      e.target.textContent = isPlaying ? '⏸ Pause' : '▶ Play';
      e.target.classList.toggle('active');
    });

    document.getElementById('showSourcesBtn').addEventListener('click', (e) => {
      showSources = !showSources;
      e.target.classList.toggle('active');
    });

    document.getElementById('showIntensityBtn').addEventListener('click', (e) => {
      showIntensity = !showIntensity;
      document.getElementById('intensityDisplay').style.display = showIntensity ? 'block' : 'none';
      e.target.classList.toggle('active');
    });

    document.getElementById('addSourceBtn').addEventListener('click', () => {
      if (sources.length < 6) {
        sources.push({
          x: 150 + sources.length * 30,
          y: 150 + sources.length * 20,
          freq: 2.0,
          amp: 1.0,
          phase: 0,
          active: true
        });
        updateSourceUI();
      }
    });

    document.getElementById('resetBtn').addEventListener('click', () => {
      time = 0;
      sources = [
        { x: 100, y: 100, freq: 2.0, amp: 1.0, phase: 0, active: true },
        { x: 300, y: 100, freq: 2.0, amp: 1.0, phase: 0, active: true }
      ];
      updateSourceUI();
    });

    document.querySelectorAll('[data-color]').forEach((btn) => {
      btn.addEventListener('click', (e) => {
        document.querySelectorAll('[data-color]').forEach((b) => b.classList.remove('active'));
        colorMode = e.target.dataset.color;
        e.target.classList.add('active');
      });
    });

    // Canvas mouse interactions
    canvas.addEventListener('mousedown', (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = ((e.clientX - rect.left) / rect.width) * GRID_WIDTH;
      const y = ((e.clientY - rect.top) / rect.height) * GRID_HEIGHT;

      for (let source of sources) {
        if (!source.active) continue;
        const dx = x - source.x;
        const dy = y - source.y;
        if (Math.sqrt(dx * dx + dy * dy) < dragRadius) {
          draggingSource = source;
          break;
        }
      }
    });

    canvas.addEventListener('mousemove', (e) => {
      if (!draggingSource) return;

      const rect = canvas.getBoundingClientRect();
      const x = ((e.clientX - rect.left) / rect.width) * GRID_WIDTH;
      const y = ((e.clientY - rect.top) / rect.height) * GRID_HEIGHT;

      draggingSource.x = Math.max(0, Math.min(GRID_WIDTH - 1, x));
      draggingSource.y = Math.max(0, Math.min(GRID_HEIGHT - 1, y));
    });

    canvas.addEventListener('mouseup', () => {
      draggingSource = null;
    });

    canvas.addEventListener('mouseleave', () => {
      draggingSource = null;
    });

    // Initialize
    updateSourceUI();
    animate();