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