physics • mechanics

Coupled Oscillators

Explore two coupled masses on springs, normal modes, energy transfer, and beat phenomena. Watch x₁ and x₂ oscillate together in superposed normal modes.

1.0 kg
1.0 kg
2.0 N/m
2.0 N/m
2.0 N/m
0.8 m
0.0 m
0.0 m/s
0.0 m/s
1.0x
Position x₁(t)
Position x₂(t)
Phase Space: x₂ vs x₁ (Lissajous)
Normal Mode q₊ (In-Phase)
Normal Mode q₋ (Out-of-Phase)
Real-Time Statistics
0.00
x₁ (m)
0.00
x₂ (m)
0.00
v₁ (m/s)
0.00
v₂ (m/s)
0.00
Total E (J)
0.00
ω₊ (rad/s)
0.00
ω₋ (rad/s)
--
Beat Period (s)
Developer Reference

Core Algorithm & Standalone Script

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

// ═════════════════════════════════════════════════════════
    // State Management
    // ═════════════════════════════════════════════════════════
    const state = {
      m1: 1.0, m2: 1.0,
      k1: 2.0, k2: 2.0, k3: 2.0,
      x1: 0.8, x2: 0.0,
      v1: 0.0, v2: 0.0,
      time: 0,
      simSpeed: 1.0,
      history: {
        x1: [], x2: [], t: [],
        qPlus: [], qMinus: [],
        time: 0
      },
      energyInit: 0
    };

    const UI = {
      canvas: document.getElementById('mainCanvas'),
      plot1: document.getElementById('plot1'),
      plot2: document.getElementById('plot2'),
      phaseCanvas: document.getElementById('phaseCanvas'),
      normalPlus: document.getElementById('normalPlus'),
      normalMinus: document.getElementById('normalMinus'),
      activeViz: 'timeseries'
    };

    // ═════════════════════════════════════════════════════════
    // Physics: RK4 Integration
    // ═════════════════════════════════════════════════════════
    function computeAccelerations(x1, x2, m1, m2, k1, k2, k3) {
      const a1 = (-k1 * x1 + k2 * (x2 - x1)) / m1;
      const a2 = (-k2 * (x2 - x1) - k3 * x2) / m2;
      return [a1, a2];
    }

    function rk4Step(x1, x2, v1, v2, m1, m2, k1, k2, k3, dt) {
      const [a1_0, a2_0] = computeAccelerations(x1, x2, m1, m2, k1, k2, k3);

      const x1_1 = x1 + v1 * dt * 0.5;
      const x2_1 = x2 + v2 * dt * 0.5;
      const v1_1 = v1 + a1_0 * dt * 0.5;
      const v2_1 = v2 + a2_0 * dt * 0.5;
      const [a1_1, a2_1] = computeAccelerations(x1_1, x2_1, m1, m2, k1, k2, k3);

      const x1_2 = x1 + v1_1 * dt * 0.5;
      const x2_2 = x2 + v2_1 * dt * 0.5;
      const v1_2 = v1 + a1_1 * dt * 0.5;
      const v2_2 = v2 + a2_1 * dt * 0.5;
      const [a1_2, a2_2] = computeAccelerations(x1_2, x2_2, m1, m2, k1, k2, k3);

      const x1_3 = x1 + v1_2 * dt;
      const x2_3 = x2 + v2_2 * dt;
      const v1_3 = v1 + a1_2 * dt;
      const v2_3 = v2 + a2_2 * dt;
      const [a1_3, a2_3] = computeAccelerations(x1_3, x2_3, m1, m2, k1, k2, k3);

      const nx1 = x1 + (dt / 6.0) * (v1 + 2 * v1_1 + 2 * v1_2 + v1_3);
      const nv1 = v1 + (dt / 6.0) * (a1_0 + 2 * a1_1 + 2 * a1_2 + a1_3);
      const nx2 = x2 + (dt / 6.0) * (v2 + 2 * v2_1 + 2 * v2_2 + v2_3);
      const nv2 = v2 + (dt / 6.0) * (a2_0 + 2 * a2_1 + 2 * a2_2 + a2_3);

      return [nx1, nx2, nv1, nv2];
    }

    function computeNormalFrequencies(m1, m2, k1, k2, k3) {
      const A = (k1 / m1 + k2 / m1) + (k2 / m2 + k3 / m2);
      const term1 = k1 / m1 + k2 / m1;
      const term2 = k2 / m2 + k3 / m2;
      const B = (term1 - term2) ** 2 + 4 * (k2 / m1) * (k2 / m2);

      const omega2Plus = 0.5 * (A + Math.sqrt(B));
      const omega2Minus = 0.5 * (A - Math.sqrt(B));

      return [Math.sqrt(Math.max(omega2Plus, 0)), Math.sqrt(Math.max(omega2Minus, 0))];
    }

    function computeTotalEnergy(x1, x2, v1, v2, m1, m2, k1, k2, k3) {
      const KE = 0.5 * m1 * v1 * v1 + 0.5 * m2 * v2 * v2;
      const PE = 0.5 * k1 * x1 * x1 + 0.5 * k2 * (x2 - x1) * (x2 - x1) + 0.5 * k3 * x2 * x2;
      return KE + PE;
    }

    // ═════════════════════════════════════════════════════════
    // Rendering: Main Animation Canvas
    // ═════════════════════════════════════════════════════════
    function drawMainAnimation() {
      const ctx = UI.canvas.getContext('2d');
      const w = UI.canvas.width;
      const h = UI.canvas.height;
      const cx = w / 2;
      const cy = h / 2;

      ctx.fillStyle = '#0a0a0a';
      ctx.fillRect(0, 0, w, h);

      // Grid
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 1;
      const scale = 40;
      for (let i = -2; i <= 2; i++) {
        const x = cx + i * scale;
        ctx.beginPath();
        ctx.moveTo(x, cy - 40);
        ctx.lineTo(x, cy + 40);
        ctx.stroke();
      }

      // Center line
      ctx.strokeStyle = '#2a2a2a';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(0, cy);
      ctx.lineTo(w, cy);
      ctx.stroke();

      // Fixed walls
      ctx.fillStyle = '#555555';
      const wallW = 8, wallH = 80;
      ctx.fillRect(20, cy - wallH / 2, wallW, wallH);
      ctx.fillRect(w - 20 - wallW, cy - wallH / 2, wallW, wallH);

      const scale_m = 30;
      const m1_radius = Math.sqrt(state.m1) * 8;
      const m2_radius = Math.sqrt(state.m2) * 8;

      const x1_pos = cx - 120 + state.x1 * scale_m;
      const x2_pos = cx + 120 + state.x2 * scale_m;

      // Spring k1 (wall to m1)
      drawSpring(ctx, 20 + wallW, cy, x1_pos - m1_radius, cy, state.x1, state.k1);

      // Spring k2 (m1 to m2)
      drawSpring(ctx, x1_pos + m1_radius, cy, x2_pos - m2_radius, cy, state.x2 - state.x1, state.k2);

      // Spring k3 (m2 to wall)
      drawSpring(ctx, x2_pos + m2_radius, cy, w - 20 - wallW, cy, state.x2, state.k3);

      // Masses (glowing circles)
      ctx.fillStyle = 'rgba(255, 34, 0, 0.3)';
      ctx.fillRect(x1_pos - m1_radius, cy - m1_radius, m1_radius * 2, m1_radius * 2);
      ctx.strokeStyle = '#ff2200';
      ctx.lineWidth = 2;
      ctx.strokeRect(x1_pos - m1_radius, cy - m1_radius, m1_radius * 2, m1_radius * 2);

      ctx.fillStyle = 'rgba(0, 200, 150, 0.3)';
      ctx.fillRect(x2_pos - m2_radius, cy - m2_radius, m2_radius * 2, m2_radius * 2);
      ctx.strokeStyle = '#00c896';
      ctx.lineWidth = 2;
      ctx.strokeRect(x2_pos - m2_radius, cy - m2_radius, m2_radius * 2, m2_radius * 2);

      // Labels
      ctx.fillStyle = '#e8e0d5';
      ctx.font = '11px DM Mono';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('m₁', x1_pos, cy - m1_radius - 14);
      ctx.fillText('m₂', x2_pos, cy - m2_radius - 14);

      // Position indicators
      ctx.font = 'bold 12px Bebas Neue';
      ctx.fillStyle = '#ff2200';
      ctx.fillText(state.x1.toFixed(2) + ' m', x1_pos, cy + 32);
      ctx.fillStyle = '#00c896';
      ctx.fillText(state.x2.toFixed(2) + ' m', x2_pos, cy + 32);
    }

    function drawSpring(ctx, x1, y1, x2, y2, disp, k) {
      const dist = Math.hypot(x2 - x1, y2 - y1);
      const coils = 6;
      const amplitude = 5;

      let color = '#e8e0d5';
      if (disp < -0.1) color = '#ff5555'; // Compressed
      else if (disp > 0.1) color = '#00c896'; // Stretched

      ctx.strokeStyle = color;
      ctx.lineWidth = 2;
      ctx.beginPath();

      const dx = (x2 - x1) / coils;
      const dy = (y2 - y1) / coils;
      const perpX = -dy / dist * amplitude;
      const perpY = dx / dist * amplitude;

      ctx.moveTo(x1, y1);
      for (let i = 1; i <= coils; i++) {
        const sign = (i % 2 === 0) ? 1 : -1;
        const px = x1 + dx * i + perpX * sign;
        const py = y1 + dy * i + perpY * sign;
        ctx.lineTo(px, py);
      }
      ctx.lineTo(x2, y2);
      ctx.stroke();
    }

    // ═════════════════════════════════════════════════════════
    // Plot Rendering
    // ═════════════════════════════════════════════════════════
    function drawTimeSeries(canvas, data, color) {
      const ctx = canvas.getContext('2d');
      const w = canvas.width;
      const h = canvas.height;

      ctx.fillStyle = '#0d0d0d';
      ctx.fillRect(0, 0, w, h);
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 1;

      // Axes
      ctx.beginPath();
      ctx.moveTo(40, h - 20);
      ctx.lineTo(w - 10, h - 20);
      ctx.stroke();

      if (data.length < 2) return;

      const minVal = Math.min(...data);
      const maxVal = Math.max(...data);
      const range = maxVal - minVal || 1;
      const yScale = (h - 40) / range;

      ctx.strokeStyle = color;
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      for (let i = 0; i < data.length; i++) {
        const x = 40 + (i / Math.max(data.length - 1, 1)) * (w - 50);
        const y = (h - 20) - (data[i] - minVal) * yScale;
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();

      // Labels
      ctx.fillStyle = '#555555';
      ctx.font = '9px DM Mono';
      ctx.textAlign = 'right';
      ctx.fillText(maxVal.toFixed(2), 35, 15);
      ctx.fillText(minVal.toFixed(2), 35, h - 5);
    }

    function drawPhaseSpace(canvas, x1Data, x2Data) {
      const ctx = canvas.getContext('2d');
      const w = canvas.width;
      const h = canvas.height;

      ctx.fillStyle = '#0d0d0d';
      ctx.fillRect(0, 0, w, h);

      if (x1Data.length < 2) return;

      const minX1 = Math.min(...x1Data);
      const maxX1 = Math.max(...x1Data);
      const minX2 = Math.min(...x2Data);
      const maxX2 = Math.max(...x2Data);
      const rangeX1 = maxX1 - minX1 || 1;
      const rangeX2 = maxX2 - minX2 || 1;

      const cx = w / 2;
      const cy = h / 2;
      const scale = Math.min(w, h) / 3;

      // Center cross
      ctx.strokeStyle = '#2a2a2a';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(cx - 30, cy);
      ctx.lineTo(cx + 30, cy);
      ctx.moveTo(cx, cy - 30);
      ctx.lineTo(cx, cy + 30);
      ctx.stroke();

      // Lissajous curve
      ctx.strokeStyle = '#ff2200';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      for (let i = 0; i < x1Data.length; i++) {
        const px = cx + ((x1Data[i] - minX1) / rangeX1 - 0.5) * scale;
        const py = cy - ((x2Data[i] - minX2) / rangeX2 - 0.5) * scale;
        if (i === 0) ctx.moveTo(px, py);
        else ctx.lineTo(px, py);
      }
      ctx.stroke();

      // Current point
      if (x1Data.length > 0) {
        const i = x1Data.length - 1;
        const px = cx + ((x1Data[i] - minX1) / rangeX1 - 0.5) * scale;
        const py = cy - ((x2Data[i] - minX2) / rangeX2 - 0.5) * scale;
        ctx.fillStyle = '#00c896';
        ctx.beginPath();
        ctx.arc(px, py, 3, 0, Math.PI * 2);
        ctx.fill();
      }

      // Axes labels
      ctx.fillStyle = '#555555';
      ctx.font = '9px DM Mono';
      ctx.textAlign = 'center';
      ctx.fillText('x₁', w - 15, cy);
      ctx.textAlign = 'left';
      ctx.fillText('x₂', cx, 15);
    }

    function drawNormalModes(canvas, qData, color) {
      const ctx = canvas.getContext('2d');
      const w = canvas.width;
      const h = canvas.height;

      ctx.fillStyle = '#0d0d0d';
      ctx.fillRect(0, 0, w, h);
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 1;

      ctx.beginPath();
      ctx.moveTo(40, h - 20);
      ctx.lineTo(w - 10, h - 20);
      ctx.stroke();

      if (qData.length < 2) return;

      const minVal = Math.min(...qData);
      const maxVal = Math.max(...qData);
      const range = maxVal - minVal || 1;
      const yScale = (h - 40) / range;

      ctx.strokeStyle = color;
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      for (let i = 0; i < qData.length; i++) {
        const x = 40 + (i / Math.max(qData.length - 1, 1)) * (w - 50);
        const y = (h - 20) - (qData[i] - minVal) * yScale;
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();
    }

    // ═════════════════════════════════════════════════════════
    // UI Event Handlers
    // ═════════════════════════════════════════════════════════
    function updateParameter(name, value) {
      state[name] = parseFloat(value);
      const units = {
        m1: ' kg', m2: ' kg',
        k1: ' N/m', k2: ' N/m', k3: ' N/m',
        x1Init: ' m', x2Init: ' m',
        v1Init: ' m/s', v2Init: ' m/s',
        simSpeed: 'x'
      };
      const id = name.replace(/Init/, 'Init').replace(/Slider/, '');
      const display = document.getElementById(name + 'Value');
      if (display) display.textContent = value + (units[name] || '');
    }

    ['m1', 'm2', 'k1', 'k2', 'k3', 'x1Init', 'x2Init', 'v1Init', 'v2Init', 'simSpeed'].forEach(param => {
      const slider = document.getElementById(param + 'Slider');
      if (slider) {
        slider.addEventListener('input', (e) => {
          const key = param.replace('Init', '');
          state[key] = parseFloat(e.target.value);
          updateParameter(param, e.target.value);
        });
      }
    });

    function toggleViz(vizName) {
      document.querySelectorAll('[id^="viz-"]').forEach(el => el.classList.remove('visible-section'));
      document.getElementById('viz-' + vizName).classList.add('visible-section');
      document.querySelectorAll('.toggle-btn').forEach(btn => btn.classList.remove('active'));
      event.target.classList.add('active');
      UI.activeViz = vizName;
    }

    function presetInPhase() {
      state.m1 = 1.0;
      state.m2 = 1.0;
      state.k1 = 2.0;
      state.k2 = 2.0;
      state.k3 = 2.0;
      state.x1 = 0.8;
      state.x2 = 0.8;
      state.v1 = 0.0;
      state.v2 = 0.0;
      resetHistoryAndUI();
    }

    function presetOutOfPhase() {
      state.m1 = 1.0;
      state.m2 = 1.0;
      state.k1 = 2.0;
      state.k2 = 2.0;
      state.k3 = 2.0;
      state.x1 = 0.8;
      state.x2 = -0.8;
      state.v1 = 0.0;
      state.v2 = 0.0;
      resetHistoryAndUI();
    }

    function presetBeats() {
      state.m1 = 1.0;
      state.m2 = 1.0;
      state.k1 = 2.0;
      state.k2 = 0.8;
      state.k3 = 2.0;
      state.x1 = 0.8;
      state.x2 = 0.0;
      state.v1 = 0.0;
      state.v2 = 0.0;
      resetHistoryAndUI();
    }

    function resetSimulation() {
      state.x1 = state.x1Init || 0.8;
      state.x2 = state.x2Init || 0.0;
      state.v1 = state.v1Init || 0.0;
      state.v2 = state.v2Init || 0.0;
      state.time = 0;
      state.history = { x1: [], x2: [], qPlus: [], qMinus: [], t: [], time: 0 };
      state.energyInit = computeTotalEnergy(state.x1, state.x2, state.v1, state.v2, state.m1, state.m2, state.k1, state.k2, state.k3);
    }

    function resetHistoryAndUI() {
      document.getElementById('m1Slider').value = state.m1;
      document.getElementById('m2Slider').value = state.m2;
      document.getElementById('k1Slider').value = state.k1;
      document.getElementById('k2Slider').value = state.k2;
      document.getElementById('k3Slider').value = state.k3;
      document.getElementById('x1InitSlider').value = state.x1;
      document.getElementById('x2InitSlider').value = state.x2;
      document.getElementById('v1InitSlider').value = state.v1;
      document.getElementById('v2InitSlider').value = state.v2;

      ['m1', 'm2', 'k1', 'k2', 'k3'].forEach(p => updateParameter(p, state[p].toFixed(1)));
      ['x1Init', 'x2Init', 'v1Init', 'v2Init'].forEach(p => updateParameter(p, state[p === 'x1Init' ? 'x1' : p === 'x2Init' ? 'x2' : p === 'v1Init' ? 'v1' : 'v2'].toFixed(1)));

      resetSimulation();
    }

    // ═════════════════════════════════════════════════════════
    // Main Simulation Loop
    // ═════════════════════════════════════════════════════════
    let lastTime = performance.now();
    let frameAccum = 0;
    const targetDt = 0.016;

    function animate(now) {
      const deltaT = Math.min((now - lastTime) / 1000, 0.05);
      lastTime = now;
      frameAccum += deltaT * state.simSpeed;

      while (frameAccum >= targetDt) {
        [state.x1, state.x2, state.v1, state.v2] = rk4Step(
          state.x1, state.x2, state.v1, state.v2,
          state.m1, state.m2, state.k1, state.k2, state.k3, targetDt
        );
        state.time += targetDt;

        if (state.history.time % 0.05 < 0.016) {
          state.history.x1.push(state.x1);
          state.history.x2.push(state.x2);
          const qPlus = (state.x1 + state.x2) / 2;
          const qMinus = (state.x1 - state.x2) / 2;
          state.history.qPlus.push(qPlus);
          state.history.qMinus.push(qMinus);
          state.history.t.push(state.time);

          if (state.history.x1.length > 500) {
            state.history.x1.shift();
            state.history.x2.shift();
            state.history.qPlus.shift();
            state.history.qMinus.shift();
            state.history.t.shift();
          }
        }
        state.history.time += targetDt;
        frameAccum -= targetDt;
      }

      // Draw
      drawMainAnimation();

      if (UI.activeViz === 'timeseries') {
        drawTimeSeries(UI.plot1, state.history.x1, '#ff2200');
        drawTimeSeries(UI.plot2, state.history.x2, '#00c896');
      } else if (UI.activeViz === 'phasespace') {
        drawPhaseSpace(UI.phaseCanvas, state.history.x1, state.history.x2);
      } else if (UI.activeViz === 'normalmodes') {
        drawNormalModes(UI.normalPlus, state.history.qPlus, '#ff9900');
        drawNormalModes(UI.normalMinus, state.history.qMinus, '#00ccff');
      }

      // Update stats
      const [wPlus, wMinus] = computeNormalFrequencies(state.m1, state.m2, state.k1, state.k2, state.k3);
      const energy = computeTotalEnergy(state.x1, state.x2, state.v1, state.v2, state.m1, state.m2, state.k1, state.k2, state.k3);
      const beatPeriod = Math.abs(wPlus - wMinus) > 0.01 ? (2 * Math.PI / Math.abs(wPlus - wMinus)).toFixed(2) : '--';

      document.getElementById('statX1').textContent = state.x1.toFixed(2);
      document.getElementById('statX2').textContent = state.x2.toFixed(2);
      document.getElementById('statV1').textContent = state.v1.toFixed(2);
      document.getElementById('statV2').textContent = state.v2.toFixed(2);
      document.getElementById('statEnergy').textContent = energy.toFixed(2);
      document.getElementById('statFreqPlus').textContent = wPlus.toFixed(2);
      document.getElementById('statFreqMinus').textContent = wMinus.toFixed(2);
      document.getElementById('statBeatPeriod').textContent = beatPeriod;

      requestAnimationFrame(animate);
    }

    // Initialize
    document.getElementById('m1Slider').value = state.m1;
    document.getElementById('m2Slider').value = state.m2;
    document.getElementById('k1Slider').value = state.k1;
    document.getElementById('k2Slider').value = state.k2;
    document.getElementById('k3Slider').value = state.k3;
    document.getElementById('x1InitSlider').value = state.x1;
    document.getElementById('x2InitSlider').value = state.x2;
    updateParameter('m1', state.m1);
    updateParameter('m2', state.m2);
    updateParameter('k1', state.k1);
    updateParameter('k2', state.k2);
    updateParameter('k3', state.k3);
    updateParameter('x1Init', state.x1);
    updateParameter('x2Init', state.x2);
    updateParameter('v1Init', state.v1);
    updateParameter('v2Init', state.v2);
    updateParameter('simSpeed', state.simSpeed);

    state.energyInit = computeTotalEnergy(state.x1, state.x2, state.v1, state.v2, state.m1, state.m2, state.k1, state.k2, state.k3);

    requestAnimationFrame(animate);