Physics
Pendulum Wave
Watch synchronized pendulums create mesmerizing wave patterns and oscillatory formations in real time.
00:00
Synchronized
About: Pendulums swing with carefully chosen lengths so they complete different numbers of oscillations in the same cycle time. This creates beautiful synchronized patterns and wave formations.
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('simulationCanvas');
const ctx = canvas.getContext('2d');
// Set canvas size
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = Math.max(rect.width - 32, 400);
canvas.height = Math.max(rect.height - 32, 500);
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Simulation parameters
let params = {
numPendulums: 15,
baseOscillations: 40,
cycleTime: 60,
amplitude: 45 * Math.PI / 180,
speed: 1,
gravity: 9.81,
showTrails: true,
trailLength: 200,
viewMode: 'side',
isPaused: false,
time: 0
};
// Pendulum data
let pendulums = [];
let trails = [];
// Initialize pendulums
function initPendulums() {
pendulums = [];
trails = [];
const g = params.gravity;
const T = params.cycleTime;
const n = params.baseOscillations;
for (let k = 1; k <= params.numPendulums; k++) {
const oscillations = n + k - 1;
const period = T / oscillations;
const length = (g * period * period) / (4 * Math.PI * Math.PI);
pendulums.push({
k: k,
length: length,
period: period,
oscillations: oscillations,
x: 0,
y: 0,
color: getColorForIndex(k - 1, params.numPendulums),
trailPoints: []
});
trails.push([]);
}
}
// Get color for pendulum
function getColorForIndex(index, total) {
const hue = (index / total) * 360;
return `hsl(${hue}, 100%, 50%)`;
}
// Calculate pendulum position
function getPendulumAngle(k, t) {
const period = pendulums[k - 1].period;
const phase = (t % period) / period;
const angle = params.amplitude * Math.cos(2 * Math.PI * phase);
return angle;
}
// Update simulation
function update(deltaTime) {
if (!params.isPaused) {
params.time += deltaTime * params.speed;
}
const T = params.cycleTime;
params.time = params.time % T;
// Update pendulum positions
for (let k = 0; k < params.numPendulums; k++) {
const pendulum = pendulums[k];
const angle = getPendulumAngle(k + 1, params.time);
// Calculate position based on view mode
if (params.viewMode === 'side') {
pendulum.x = pendulum.length * Math.sin(angle);
pendulum.y = pendulum.length * Math.cos(angle);
} else if (params.viewMode === 'end') {
const xOffset = (k - params.numPendulums / 2) * 30;
pendulum.x = pendulum.length * Math.sin(angle);
pendulum.y = xOffset;
} else if (params.viewMode === 'top') {
const yOffset = (k - params.numPendulums / 2) * 30;
pendulum.x = pendulum.length * Math.sin(angle);
pendulum.y = yOffset;
}
// Add to trail
if (params.showTrails) {
if (!pendulum.trailPoints) {
pendulum.trailPoints = [];
}
pendulum.trailPoints.push({ x: pendulum.x, y: pendulum.y, t: params.time });
if (pendulum.trailPoints.length > params.trailLength) {
pendulum.trailPoints.shift();
}
} else {
pendulum.trailPoints = [];
}
}
}
// Render
function render() {
// Clear canvas
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const centerX = canvas.width / 2;
const centerY = canvas.height / 4;
const scale = 150;
if (params.viewMode === 'side') {
renderSideView(centerX, centerY, scale);
} else if (params.viewMode === 'end') {
renderEndView(centerX, centerY, scale);
} else if (params.viewMode === 'top') {
renderTopView(centerX, centerY, scale);
}
}
function renderSideView(centerX, centerY, scale) {
// Draw ceiling
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX - 400, centerY);
ctx.lineTo(centerX + 400, centerY);
ctx.stroke();
// Draw pendulums with trails
for (let k = 0; k < params.numPendulums; k++) {
const pendulum = pendulums[k];
const xSpacing = (800 / (params.numPendulums + 1));
const ceilingX = centerX - 400 + (k + 1) * xSpacing;
// Draw trails
if (params.showTrails && pendulum.trailPoints.length > 1) {
ctx.strokeStyle = pendulum.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.moveTo(ceilingX + pendulum.trailPoints[0].x * scale, centerY + pendulum.trailPoints[0].y * scale);
for (let i = 1; i < pendulum.trailPoints.length; i++) {
const point = pendulum.trailPoints[i];
ctx.lineTo(ceilingX + point.x * scale, centerY + point.y * scale);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
// Draw string
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(ceilingX, centerY);
ctx.lineTo(ceilingX + pendulum.x * scale, centerY + pendulum.y * scale);
ctx.stroke();
ctx.globalAlpha = 1;
// Draw bob (glowing)
const bobX = ceilingX + pendulum.x * scale;
const bobY = centerY + pendulum.y * scale;
// Glow
const gradient = ctx.createRadialGradient(bobX, bobY, 0, bobX, bobY, 12);
gradient.addColorStop(0, pendulum.color + '80');
gradient.addColorStop(1, pendulum.color + '00');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(bobX, bobY, 12, 0, 2 * Math.PI);
ctx.fill();
// Ball
ctx.fillStyle = pendulum.color;
ctx.beginPath();
ctx.arc(bobX, bobY, 6, 0, 2 * Math.PI);
ctx.fill();
// Highlight
ctx.fillStyle = '#ffffff';
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.arc(bobX - 2, bobY - 2, 2, 0, 2 * Math.PI);
ctx.fill();
ctx.globalAlpha = 1;
}
}
function renderEndView(centerX, centerY, scale) {
// Draw ceiling from side
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX - 100, centerY - 30);
ctx.lineTo(centerX + 100, centerY - 30);
ctx.stroke();
// Draw pendulum bobs from front
for (let k = 0; k < params.numPendulums; k++) {
const pendulum = pendulums[k];
const ceilingX = centerX;
const ceilingY = centerY - 30;
// Draw trails
if (params.showTrails && pendulum.trailPoints.length > 1) {
ctx.strokeStyle = pendulum.color;
ctx.lineWidth = 2;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.moveTo(ceilingX + pendulum.trailPoints[0].x * scale, ceilingY + pendulum.trailPoints[0].y * scale);
for (let i = 1; i < pendulum.trailPoints.length; i++) {
const point = pendulum.trailPoints[i];
ctx.lineTo(ceilingX + point.x * scale, ceilingY + point.y * scale);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
// Draw bob
const bobX = ceilingX + pendulum.x * scale;
const bobY = ceilingY + pendulum.y * scale;
// Glow
const gradient = ctx.createRadialGradient(bobX, bobY, 0, bobX, bobY, 12);
gradient.addColorStop(0, pendulum.color + '80');
gradient.addColorStop(1, pendulum.color + '00');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(bobX, bobY, 12, 0, 2 * Math.PI);
ctx.fill();
// Ball
ctx.fillStyle = pendulum.color;
ctx.beginPath();
ctx.arc(bobX, bobY, 6, 0, 2 * Math.PI);
ctx.fill();
// Highlight
ctx.fillStyle = '#ffffff';
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.arc(bobX - 2, bobY - 2, 2, 0, 2 * Math.PI);
ctx.fill();
ctx.globalAlpha = 1;
}
}
function renderTopView(centerX, centerY, scale) {
// Draw pendulum bobs from top
for (let k = 0; k < params.numPendulums; k++) {
const pendulum = pendulums[k];
const baseX = centerX;
const baseY = centerY + (k - params.numPendulums / 2) * 25;
// Draw trails
if (params.showTrails && pendulum.trailPoints.length > 1) {
ctx.strokeStyle = pendulum.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.moveTo(baseX + pendulum.trailPoints[0].x * scale, baseY);
for (let i = 1; i < pendulum.trailPoints.length; i++) {
const point = pendulum.trailPoints[i];
ctx.lineTo(baseX + point.x * scale, baseY);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
// Draw bob
const bobX = baseX + pendulum.x * scale;
const bobY = baseY;
// Glow
const gradient = ctx.createRadialGradient(bobX, bobY, 0, bobX, bobY, 12);
gradient.addColorStop(0, pendulum.color + '80');
gradient.addColorStop(1, pendulum.color + '00');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(bobX, bobY, 12, 0, 2 * Math.PI);
ctx.fill();
// Ball
ctx.fillStyle = pendulum.color;
ctx.beginPath();
ctx.arc(bobX, bobY, 6, 0, 2 * Math.PI);
ctx.fill();
// Highlight
ctx.fillStyle = '#ffffff';
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.arc(bobX - 2, bobY - 2, 2, 0, 2 * Math.PI);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// Pattern detection
function getPatternName(t) {
const T = params.cycleTime;
const phase = (t / T) % 1;
if (phase < 0.1 || phase > 0.9) return 'Synchronized';
if (phase > 0.2 && phase < 0.3) return 'Wave Pattern';
if (phase > 0.4 && phase < 0.6) return 'Anti-Sync';
if (phase > 0.7 && phase < 0.8) return 'Diagonal Sweep';
return 'Oscillating';
}
// Update UI
function updateUI() {
const T = params.cycleTime;
const progress = (params.time / T) * 100;
document.getElementById('progressBar').style.width = progress + '%';
const minutes = Math.floor(params.time / 60);
const seconds = Math.floor(params.time % 60);
document.getElementById('timeDisplay').textContent =
String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
document.getElementById('patternInfo').textContent = getPatternName(params.time);
document.getElementById('numPendDisplay').textContent = params.numPendulums;
document.getElementById('baseOscDisplay').textContent = params.baseOscillations;
document.getElementById('cycleTimeDisplay').textContent = params.cycleTime;
document.getElementById('amplitudeDisplay').textContent = Math.round(params.amplitude * 180 / Math.PI);
document.getElementById('speedDisplay').textContent = params.speed.toFixed(1) + '×';
document.getElementById('gravityDisplay').textContent = params.gravity.toFixed(1) + '×';
document.getElementById('trailLengthDisplay').textContent = params.trailLength;
}
// Event listeners
document.getElementById('numPendulums').addEventListener('input', (e) => {
params.numPendulums = parseInt(e.target.value);
initPendulums();
});
document.getElementById('baseOscillations').addEventListener('input', (e) => {
params.baseOscillations = parseInt(e.target.value);
initPendulums();
});
document.getElementById('cycleTime').addEventListener('input', (e) => {
params.cycleTime = parseInt(e.target.value);
initPendulums();
});
document.getElementById('amplitude').addEventListener('input', (e) => {
params.amplitude = (parseInt(e.target.value) * Math.PI) / 180;
});
document.getElementById('speed').addEventListener('input', (e) => {
params.speed = parseFloat(e.target.value);
});
document.getElementById('gravity').addEventListener('input', (e) => {
params.gravity = parseFloat(e.target.value) * 9.81;
initPendulums();
});
document.getElementById('showTrails').addEventListener('change', (e) => {
params.showTrails = e.target.checked;
document.getElementById('trailLengthGroup').style.opacity = params.showTrails ? '1' : '0.5';
});
document.getElementById('trailLength').addEventListener('input', (e) => {
params.trailLength = parseInt(e.target.value);
});
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
params.viewMode = e.target.dataset.view;
});
});
document.getElementById('pauseBtn').addEventListener('click', (e) => {
params.isPaused = !params.isPaused;
e.target.textContent = params.isPaused ? 'Resume' : 'Pause';
e.target.classList.toggle('active');
});
document.getElementById('resetBtn').addEventListener('click', () => {
params.time = 0;
initPendulums();
});
// Animation loop
let lastTime = performance.now();
function animate(currentTime) {
const deltaTime = Math.min((currentTime - lastTime) / 1000, 0.05);
lastTime = currentTime;
update(deltaTime);
render();
updateUI();
requestAnimationFrame(animate);
}
// Initialize
initPendulums();
requestAnimationFrame(animate);