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
• 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();