Physics Lab
STANDING WAVES
Explore standing waves, harmonics, and vibrational modes. Visualize nodes, antinodes, and the interference of traveling waves.
Standing Wave Simulation
Frequency
110.0
Hz
Wavelength
2.00
m
Nodes
2
Antinodes
1
Two-Wave Decomposition
3D String Oscillation (Isometric)
2D Membrane Modes (m,n)
1
1
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// Global state
const state = {
isPlaying: true,
harmonic: 1,
waveSpeed: 100,
amplitude: 0.5,
stringLength: 2,
damping: 0,
boundaryCondition: 'fixed-fixed',
animationSpeed: 1,
time: 0,
showNodes: true,
showAntinodes: true,
showEnvelope: true,
showGrid: false,
mode: 'string',
modeM: 1,
modeN: 1,
decay: 1,
};
// DOM Elements
const stringCanvas = document.getElementById('stringCanvas');
const decompositionCanvas = document.getElementById('decompositionCanvas');
const canvas3D = document.getElementById('canvas3D');
const membraneCanvas = document.getElementById('membraneCanvas');
const stringCtx = stringCanvas.getContext('2d');
const decompositionCtx = decompositionCanvas.getContext('2d');
const ctx3D = canvas3D.getContext('2d');
const membraneCtx = membraneCanvas.getContext('2d');
// Initialize harmonic buttons
function initHarmonicSelector() {
const selector = document.getElementById('harmonicSelector');
selector.innerHTML = '';
for (let n = 1; n <= 8; n++) {
const btn = document.createElement('button');
btn.className = `harmonic-btn ${n === 1 ? 'active' : ''}`;
btn.textContent = n;
btn.onclick = () => {
state.harmonic = n;
updateHarmonicSelector();
document.getElementById('harmonic').value = n;
document.getElementById('harmonicLabel').textContent = n;
updateStats();
};
selector.appendChild(btn);
}
}
function updateHarmonicSelector() {
document.querySelectorAll('.harmonic-btn').forEach((btn, i) => {
btn.classList.toggle('active', i + 1 === state.harmonic);
});
}
// Update stats
function updateStats() {
const n = state.harmonic;
const L = state.stringLength;
const v = state.waveSpeed;
const frequency = (n * v) / (2 * L);
const wavelength = (2 * L) / n;
const nodes = n + 1;
const antinodes = n;
document.getElementById('freqStat').textContent = frequency.toFixed(1);
document.getElementById('wavelengthStat').textContent = wavelength.toFixed(2);
document.getElementById('nodesStat').textContent = nodes;
document.getElementById('antinodesStat').textContent = antinodes;
}
// Drawing functions
function drawStringWave() {
const canvas = stringCanvas;
const ctx = stringCtx;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
// Set actual canvas size
canvas.width = width;
canvas.height = height;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, width, height);
const n = state.harmonic;
const L = state.stringLength;
const A = state.amplitude;
const v = state.waveSpeed;
const centerY = height / 2;
const pixelsPerMeter = width / L;
// Draw grid
if (state.showGrid) {
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 0.5;
for (let i = 0; i <= n; i++) {
const x = (i * width) / n;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
// Calculate wave data
const waveData = [];
for (let x = 0; x < width; x++) {
const position = x / pixelsPerMeter;
const k = (n * Math.PI) / L;
const omega = (n * Math.PI * v) / L;
const y = 2 * A * Math.sin(k * position) * Math.cos(omega * state.time) * state.decay;
const amplitude = 2 * A * Math.abs(Math.sin(k * position));
waveData.push({ x, y, amplitude });
}
// Draw envelope
if (state.showEnvelope) {
ctx.strokeStyle = 'rgba(68, 136, 255, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
for (let i = 0; i < waveData.length; i++) {
const px = waveData[i].x;
const py = centerY - waveData[i].amplitude * 50;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.beginPath();
for (let i = 0; i < waveData.length; i++) {
const px = waveData[i].x;
const py = centerY + waveData[i].amplitude * 50;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
}
// Draw wave with color gradient
for (let i = 0; i < waveData.length - 1; i++) {
const d1 = waveData[i];
const d2 = waveData[i + 1];
const amp = (d1.amplitude + d2.amplitude) / 2;
const normalized = Math.min(amp / (2 * A), 1);
// Color: blue at nodes, red at antinodes
const r = Math.floor(255 * normalized);
const g = Math.floor(136 * (1 - normalized));
const b = Math.floor(255 * (1 - normalized));
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(d1.x, centerY - d1.y * 50);
ctx.lineTo(d2.x, centerY - d2.y * 50);
ctx.stroke();
}
// Draw nodes
if (state.showNodes) {
ctx.fillStyle = '#4488ff';
for (let m = 0; m <= n; m++) {
const nodeX = (m * width) / n;
ctx.beginPath();
ctx.arc(nodeX, centerY, 4, 0, Math.PI * 2);
ctx.fill();
}
}
// Draw antinodes
if (state.showAntinodes) {
ctx.fillStyle = '#ff2200';
for (let m = 0; m < n; m++) {
const antinodeX = ((2 * m + 1) * width) / (2 * n);
ctx.beginPath();
ctx.arc(antinodeX, centerY, 3, 0, Math.PI * 2);
ctx.fill();
}
}
// Draw boundary conditions
ctx.strokeStyle = '#e8e0d5';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(width, centerY);
ctx.stroke();
// Boundary markers
ctx.fillStyle = '#e8e0d5';
ctx.fillRect(-2, centerY - 5, 4, 10);
ctx.fillRect(width - 2, centerY - 5, 4, 10);
}
function drawDecomposition() {
const canvas = decompositionCanvas;
const ctx = decompositionCtx;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = width;
canvas.height = height;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, width, height);
const n = state.harmonic;
const L = state.stringLength;
const A = state.amplitude;
const v = state.waveSpeed;
const centerY = height / 2;
const pixelsPerMeter = width / (L * 1.5);
const rowHeight = height / 3;
// Wave 1: moving right (red)
ctx.fillStyle = 'rgba(26, 26, 26, 0.7)';
ctx.fillRect(0, 0, width, rowHeight);
const k = (n * Math.PI) / L;
const omega = (n * Math.PI * v) / L;
const phase = omega * state.time;
// Right-moving wave
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < width; x++) {
const pos = x / pixelsPerMeter;
const y = A * Math.sin(k * pos - phase) * state.decay;
const py = rowHeight / 2 - y * 50;
if (x === 0) ctx.moveTo(x, py);
else ctx.lineTo(x, py);
}
ctx.stroke();
// Wave 2: moving left (blue)
ctx.fillStyle = 'rgba(26, 26, 26, 0.7)';
ctx.fillRect(0, rowHeight, width, rowHeight);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < width; x++) {
const pos = x / pixelsPerMeter;
const y = A * Math.sin(k * pos + phase) * state.decay;
const py = rowHeight + rowHeight / 2 - y * 50;
if (x === 0) ctx.moveTo(x, py);
else ctx.lineTo(x, py);
}
ctx.stroke();
// Standing wave (sum)
ctx.fillStyle = 'rgba(26, 26, 26, 0.7)';
ctx.fillRect(0, rowHeight * 2, width, rowHeight);
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 3;
ctx.beginPath();
for (let x = 0; x < width; x++) {
const pos = x / pixelsPerMeter;
const y = 2 * A * Math.sin(k * pos) * Math.cos(omega * state.time) * state.decay;
const py = rowHeight * 2 + rowHeight / 2 - y * 50;
if (x === 0) ctx.moveTo(x, py);
else ctx.lineTo(x, py);
}
ctx.stroke();
// Labels
ctx.fillStyle = '#555555';
ctx.font = '12px "DM Mono"';
ctx.fillText('→ Right-moving wave', 10, 20);
ctx.fillText('← Left-moving wave', 10, rowHeight + 20);
ctx.fillText('= Standing wave (sum)', 10, rowHeight * 2 + 20);
}
function draw3D() {
const canvas = canvas3D;
const ctx = ctx3D;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = width;
canvas.height = height;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, width, height);
const n = state.harmonic;
const L = state.stringLength;
const A = state.amplitude;
const v = state.waveSpeed;
const centerX = width / 2;
const centerY = height / 2;
const scale = Math.min(width, height) / 8;
// Isometric projection helper
function isometricProject(x, y, z) {
const angle = Math.PI / 6;
const screenX = (x - y) * Math.cos(angle);
const screenY = (x + y) * Math.sin(angle) - z;
return { x: centerX + screenX * scale, y: centerY + screenY * scale };
}
const k = (n * Math.PI) / L;
const omega = (n * Math.PI * v) / L;
// Draw 3D string oscillating
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
const segments = 50;
for (let seg = 0; seg < segments; seg++) {
const x1 = (seg / segments) * L - L / 2;
const x2 = ((seg + 1) / segments) * L - L / 2;
const z1 = 2 * A * Math.sin(k * (x1 + L / 2)) * Math.cos(omega * state.time) * state.decay;
const z2 = 2 * A * Math.sin(k * (x2 + L / 2)) * Math.cos(omega * state.time) * state.decay;
const p1 = isometricProject(x1, 0, z1);
const p2 = isometricProject(x2, 0, z2);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
// Draw reference axes
ctx.strokeStyle = '#555555';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
const p0 = isometricProject(-L / 2, 0, 0);
const pL = isometricProject(L / 2, 0, 0);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(pL.x, pL.y);
ctx.stroke();
ctx.setLineDash([]);
// Draw nodes
if (state.showNodes) {
ctx.fillStyle = '#4488ff';
for (let m = 0; m <= n; m++) {
const x = (m / n) * L - L / 2;
const p = isometricProject(x, 0, 0);
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
// Labels
ctx.fillStyle = '#555555';
ctx.font = '12px "DM Mono"';
ctx.fillText('3D Standing Wave (Isometric)', 10, 20);
}
function drawMembrane() {
const canvas = membraneCanvas;
const ctx = membraneCtx;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = width;
canvas.height = height;
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, width, height);
const m = state.modeM;
const n = state.modeN;
const scale = 1;
// Simple 2D surface plot representation
const pixelSize = 4;
const rows = Math.floor(height / pixelSize);
const cols = Math.floor(width / pixelSize);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = (col / cols) * Math.PI;
const y = (row / rows) * Math.PI;
const z = Math.sin(m * x) * Math.sin(n * y) * Math.cos(state.time * 2) * state.decay;
const normalized = (z + 1) / 2;
const hue = normalized * 240;
const r = Math.sin(hue * Math.PI / 180) * 255;
const g = Math.sin((hue + 120) * Math.PI / 180) * 255;
const b = Math.sin((hue + 240) * Math.PI / 180) * 255;
ctx.fillStyle = `rgb(${Math.max(0, r)}, ${Math.max(0, g)}, ${Math.max(0, b)})`;
ctx.fillRect(col * pixelSize, row * pixelSize, pixelSize, pixelSize);
}
}
// Draw boundary
ctx.strokeStyle = '#e8e0d5';
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, width, height);
}
// Animation loop
function animate() {
if (state.isPlaying) {
state.time += 0.016 * state.animationSpeed;
state.decay = Math.max(0, 1 - state.time * state.damping * 0.1);
}
if (state.mode === 'string') {
drawStringWave();
} else if (state.mode === 'decomposition') {
drawDecomposition();
} else if (state.mode === '3d') {
draw3D();
} else if (state.mode === 'membrane') {
drawMembrane();
}
requestAnimationFrame(animate);
}
// Event listeners
document.getElementById('harmonic').addEventListener('input', (e) => {
state.harmonic = parseInt(e.target.value);
document.getElementById('harmonicLabel').textContent = state.harmonic;
updateHarmonicSelector();
updateStats();
});
document.getElementById('waveSpeed').addEventListener('input', (e) => {
state.waveSpeed = parseInt(e.target.value);
document.getElementById('waveSpeedLabel').textContent = state.waveSpeed;
updateStats();
});
document.getElementById('amplitude').addEventListener('input', (e) => {
state.amplitude = parseFloat(e.target.value);
document.getElementById('amplitudeLabel').textContent = state.amplitude.toFixed(2);
});
document.getElementById('stringLength').addEventListener('input', (e) => {
state.stringLength = parseFloat(e.target.value);
document.getElementById('stringLengthLabel').textContent = state.stringLength.toFixed(2);
updateStats();
});
document.getElementById('damping').addEventListener('input', (e) => {
state.damping = parseFloat(e.target.value);
document.getElementById('dampingLabel').textContent = state.damping.toFixed(2);
});
document.getElementById('animationSpeed').addEventListener('input', (e) => {
state.animationSpeed = parseFloat(e.target.value);
document.getElementById('animationSpeedLabel').textContent = state.animationSpeed.toFixed(1) + '×';
});
document.getElementById('boundaryCondition').addEventListener('change', (e) => {
state.boundaryCondition = e.target.value;
});
document.getElementById('playPauseBtn').addEventListener('click', () => {
state.isPlaying = !state.isPlaying;
const btn = document.getElementById('playPauseBtn');
btn.textContent = state.isPlaying ? 'Pause' : 'Play';
btn.classList.toggle('active');
});
document.getElementById('resetBtn').addEventListener('click', () => {
state.time = 0;
state.decay = 1;
});
document.getElementById('showNodes').addEventListener('change', (e) => {
state.showNodes = e.target.checked;
});
document.getElementById('showAntinodes').addEventListener('change', (e) => {
state.showAntinodes = e.target.checked;
});
document.getElementById('showEnvelope').addEventListener('change', (e) => {
state.showEnvelope = e.target.checked;
});
document.getElementById('showGrid').addEventListener('change', (e) => {
state.showGrid = e.target.checked;
});
document.getElementById('modeM').addEventListener('input', (e) => {
state.modeM = parseInt(e.target.value);
document.getElementById('modeMLabel').textContent = state.modeM;
});
document.getElementById('modeN').addEventListener('input', (e) => {
state.modeN = parseInt(e.target.value);
document.getElementById('modeNLabel').textContent = state.modeN;
});
// Mode switching
document.querySelectorAll('.mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
state.mode = mode;
document.querySelectorAll('.mode-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.mode-section').forEach((sec) => sec.classList.remove('active'));
document.getElementById(mode + '-mode').classList.add('active');
});
});
// Initialize
initHarmonicSelector();
updateStats();
animate();