physics education

Spring-Mass SHM

Interactive Simple Harmonic Motion simulator with damping, phase space analysis, and real-time energy calculations.

2.0 N/m
1.0 kg
0.2 N·s/m
100 px
0.0 m/s
1.0x
Natural Frequency (ω₀)
4.47 rad/s
Damped Frequency (ωd)
4.46 rad/s
Period (T)
1.41 s
Regime
Underdamped
Underdamped
Position (x)
100.0 px
Velocity (ẋ)
0.0 m/s
Kinetic Energy
0.00 J
Potential Energy
10.00 J
Total Energy
10.00 J

Spring Animation

Phase Space Portrait

Developer Reference

Core Algorithm & Standalone Script

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

// ============================================
    // State & Configuration
    // ============================================
    const STATE = {
      k: 2.0,          // spring constant (N/m)
      m: 1.0,          // mass (kg)
      b: 0.2,          // damping (N·s/m)
      x: 100,          // position (px, relative to equilibrium)
      v: 0,            // velocity (m/s)
      t: 0,            // time (s)
      running: false,
      dt: 0.016,       // timestep (s)
      timeScale: 1.0,  // simulation speed multiplier
      initialA: 100,   // initial displacement
      initialV0: 0,    // initial velocity
    };

    const FLAGS = {
      showPhaseSpace: true,
      showEnergy: true,
      showVelocity: false,
      showTrail: true,
    };

    // Phase space history for trailing
    let phaseHistory = [];
    let maxHistoryLength = 500;

    // ============================================
    // Canvas Setup
    // ============================================
    const springCanvas = document.getElementById('springCanvas');
    const phaseCanvas = document.getElementById('phaseCanvas');
    const springCtx = springCanvas.getContext('2d');
    const phaseCtx = phaseCanvas.getContext('2d');

    // ============================================
    // Physics Calculations
    // ============================================
    function calculateOmega0() {
      return Math.sqrt(STATE.k / STATE.m);
    }

    function calculateGamma() {
      return STATE.b / (2 * STATE.m);
    }

    function calculateOmegaD() {
      const omega0 = calculateOmega0();
      const gamma = calculateGamma();
      const wd2 = omega0 * omega0 - gamma * gamma;
      return wd2 > 0 ? Math.sqrt(wd2) : 0;
    }

    function getRegime() {
      const criticalB = 2 * Math.sqrt(STATE.k * STATE.m);
      if (STATE.b < criticalB - 0.01) return 'underdamped';
      if (Math.abs(STATE.b - criticalB) < 0.01) return 'critical';
      return 'overdamped';
    }

    function updatePhysics(dt) {
      if (!STATE.running) return;

      const scaledDt = dt * STATE.timeScale;
      const a = -(STATE.k / STATE.m) * STATE.x - (STATE.b / STATE.m) * STATE.v;

      // RK4 integration
      const k1x = STATE.v;
      const k1v = a;

      const k2x = STATE.v + 0.5 * scaledDt * k1v;
      const k2v = -(STATE.k / STATE.m) * (STATE.x + 0.5 * scaledDt * k1x) - (STATE.b / STATE.m) * k2x;

      const k3x = STATE.v + 0.5 * scaledDt * k2v;
      const k3v = -(STATE.k / STATE.m) * (STATE.x + 0.5 * scaledDt * k2x) - (STATE.b / STATE.m) * k3x;

      const k4x = STATE.v + scaledDt * k3v;
      const k4v = -(STATE.k / STATE.m) * (STATE.x + scaledDt * k3x) - (STATE.b / STATE.m) * k4x;

      STATE.x += (scaledDt / 6) * (k1x + 2 * k2x + 2 * k3x + k4x);
      STATE.v += (scaledDt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
      STATE.t += scaledDt;

      // Add to phase history
      if (phaseHistory.length === 0 || Math.abs(STATE.x - phaseHistory[phaseHistory.length - 1].x) > 2 || Math.abs(STATE.v - phaseHistory[phaseHistory.length - 1].v) > 0.1) {
        phaseHistory.push({ x: STATE.x, v: STATE.v });
        if (phaseHistory.length > maxHistoryLength) {
          phaseHistory.shift();
        }
      }
    }

    // ============================================
    // Drawing Functions
    // ============================================
    function drawSpring(ctx) {
      const centerX = ctx.canvas.width / 2;
      const ceilingY = 30;
      const equilibriumY = 150;
      const massHeight = 40;
      const massWidth = 60;

      // Clear canvas
      ctx.fillStyle = '#111111';
      ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

      // Grid background (subtle)
      ctx.strokeStyle = 'rgba(85, 85, 85, 0.1)';
      ctx.lineWidth = 1;
      for (let i = 0; i < ctx.canvas.width; i += 50) {
        ctx.beginPath();
        ctx.moveTo(i, 0);
        ctx.lineTo(i, ctx.canvas.height);
        ctx.stroke();
      }
      for (let i = 0; i < ctx.canvas.height; i += 50) {
        ctx.beginPath();
        ctx.moveTo(0, i);
        ctx.lineTo(ctx.canvas.width, i);
        ctx.stroke();
      }

      // Ceiling
      ctx.fillStyle = '#555555';
      ctx.fillRect(centerX - 80, ceilingY - 10, 160, 10);
      ctx.strokeStyle = '#888888';
      ctx.lineWidth = 2;
      ctx.strokeRect(centerX - 80, ceilingY - 10, 160, 10);

      // Spring attachment point
      ctx.fillStyle = '#aaaaaa';
      ctx.fillRect(centerX - 4, ceilingY, 8, 8);

      // Current mass position
      const massY = equilibriumY + STATE.x;

      // Draw spring as zigzag
      drawSpringCoil(ctx, centerX, ceilingY + 8, centerX, massY, 20);

      // Equilibrium indicator (dotted line)
      ctx.strokeStyle = 'rgba(85, 85, 85, 0.5)';
      ctx.lineWidth = 1;
      ctx.setLineDash([4, 4]);
      ctx.beginPath();
      ctx.moveTo(30, equilibriumY);
      ctx.lineTo(ctx.canvas.width - 30, equilibriumY);
      ctx.stroke();
      ctx.setLineDash([]);

      // Equilibrium label
      ctx.fillStyle = '#555555';
      ctx.font = 'bold 12px "DM Mono"';
      ctx.fillText('x=0', 40, equilibriumY - 8);

      // Mass block
      const regime = getRegime();
      if (regime === 'underdamped') {
        ctx.fillStyle = 'rgba(255, 34, 0, 0.3)';
      } else if (regime === 'critical') {
        ctx.fillStyle = 'rgba(245, 197, 24, 0.3)';
      } else {
        ctx.fillStyle = 'rgba(0, 200, 150, 0.3)';
      }
      ctx.shadowColor = 'rgba(255, 200, 100, 0.5)';
      ctx.shadowBlur = 15;
      ctx.shadowOffsetX = 0;
      ctx.shadowOffsetY = 0;
      ctx.fillRect(centerX - massWidth / 2, massY - massHeight / 2, massWidth, massHeight);
      ctx.shadowColor = 'transparent';

      ctx.strokeStyle = regime === 'underdamped' ? '#ff2200' : regime === 'critical' ? '#f5c518' : '#00c896';
      ctx.lineWidth = 2;
      ctx.strokeRect(centerX - massWidth / 2, massY - massHeight / 2, massWidth, massHeight);

      // Mass label
      ctx.fillStyle = '#e8e0d5';
      ctx.font = 'bold 14px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.fillText('m', centerX, massY + 5);
      ctx.textAlign = 'left';

      // Displacement arrow
      if (Math.abs(STATE.x) > 2) {
        drawArrow(ctx, centerX + 120, equilibriumY, centerX + 120, massY, STATE.x > 0 ? '#00c896' : '#ff2200', 'x');
      }

      // Velocity arrow (optional)
      if (FLAGS.showVelocity && Math.abs(STATE.v) > 0.1) {
        const vScale = 30;
        const vLength = Math.min(Math.abs(STATE.v) * vScale, 80);
        drawArrow(ctx, centerX - 140, equilibriumY, centerX - 140 + (STATE.v > 0 ? vLength : -vLength), equilibriumY, '#00c896', 'v');
      }

      // Current values overlay
      ctx.fillStyle = '#e8e0d5';
      ctx.font = '12px "DM Mono"';
      ctx.fillText(`x: ${STATE.x.toFixed(1)} px`, 20, 20);
      ctx.fillText(`v: ${STATE.v.toFixed(2)} m/s`, 20, 40);
      ctx.fillText(`t: ${STATE.t.toFixed(2)} s`, 20, 60);
    }

    function drawSpringCoil(ctx, x1, y1, x2, y2, coils) {
      const totalDist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
      const dx = (x2 - x1) / totalDist;
      const dy = (y2 - y1) / totalDist;
      const perpX = -dy;
      const perpY = dx;

      const coilWidth = 12;
      const amplitude = 6;

      ctx.strokeStyle = '#ff2200';
      ctx.lineWidth = 2;
      ctx.beginPath();

      for (let i = 0; i <= coils * 2; i++) {
        const t = i / (coils * 2);
        const x = x1 + t * (x2 - x1);
        const y = y1 + t * (y2 - y1);

        const offset = Math.sin(i * Math.PI) * amplitude;
        const px = x + perpX * offset;
        const py = y + perpY * offset;

        if (i === 0) {
          ctx.moveTo(px, py);
        } else {
          ctx.lineTo(px, py);
        }
      }

      ctx.stroke();
    }

    function drawArrow(ctx, fromX, fromY, toX, toY, color, label) {
      const headlen = 12;
      const angle = Math.atan2(toY - fromY, toX - fromX);

      ctx.strokeStyle = color;
      ctx.fillStyle = color;
      ctx.lineWidth = 2;

      // Arrow shaft
      ctx.beginPath();
      ctx.moveTo(fromX, fromY);
      ctx.lineTo(toX, toY);
      ctx.stroke();

      // Arrow head
      ctx.beginPath();
      ctx.moveTo(toX, toY);
      ctx.lineTo(toX - headlen * Math.cos(angle - Math.PI / 6), toY - headlen * Math.sin(angle - Math.PI / 6));
      ctx.lineTo(toX - headlen * Math.cos(angle + Math.PI / 6), toY - headlen * Math.sin(angle + Math.PI / 6));
      ctx.closePath();
      ctx.fill();

      // Label
      ctx.fillStyle = color;
      ctx.font = 'bold 12px "DM Mono"';
      ctx.fillText(label, toX + 10, toY - 5);
    }

    function drawPhaseSpace(ctx) {
      const width = ctx.canvas.width;
      const height = ctx.canvas.height;
      const padding = 50;
      const plotX = padding;
      const plotY = padding;
      const plotWidth = width - 2 * padding;
      const plotHeight = height - 2 * padding;

      // Clear canvas
      ctx.fillStyle = '#111111';
      ctx.fillRect(0, 0, width, height);

      // Axes
      ctx.strokeStyle = '#555555';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(plotX, plotY + plotHeight / 2);
      ctx.lineTo(plotX + plotWidth, plotY + plotHeight / 2);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(plotX + plotWidth / 2, plotY);
      ctx.lineTo(plotX + plotWidth / 2, plotY + plotHeight);
      ctx.stroke();

      // Grid
      ctx.strokeStyle = 'rgba(85, 85, 85, 0.2)';
      ctx.lineWidth = 1;
      for (let i = -5; i <= 5; i += 1) {
        const x = plotX + plotWidth / 2 + (i * plotWidth) / 12;
        ctx.beginPath();
        ctx.moveTo(x, plotY);
        ctx.lineTo(x, plotY + plotHeight);
        ctx.stroke();

        const y = plotY + plotHeight / 2 + (i * plotHeight) / 12;
        ctx.beginPath();
        ctx.moveTo(plotX, y);
        ctx.lineTo(plotX + plotWidth, y);
        ctx.stroke();
      }

      // Scale: x from -150 to 150, v from -10 to 10
      const xMax = 150;
      const vMax = 10;

      // Draw trajectory trail
      if (FLAGS.showTrail && phaseHistory.length > 1) {
        ctx.strokeStyle = 'rgba(255, 34, 0, 0.5)';
        ctx.lineWidth = 1;
        ctx.beginPath();

        for (let i = 0; i < phaseHistory.length; i++) {
          const point = phaseHistory[i];
          const px = plotX + plotWidth / 2 + (point.x / xMax) * (plotWidth / 2);
          const py = plotY + plotHeight / 2 - (point.v / vMax) * (plotHeight / 2);

          if (i === 0) {
            ctx.moveTo(px, py);
          } else {
            ctx.lineTo(px, py);
          }
        }
        ctx.stroke();
      }

      // Draw current state point
      const px = plotX + plotWidth / 2 + (STATE.x / xMax) * (plotWidth / 2);
      const py = plotY + plotHeight / 2 - (STATE.v / vMax) * (plotHeight / 2);

      ctx.fillStyle = '#00c896';
      ctx.beginPath();
      ctx.arc(px, py, 6, 0, 2 * Math.PI);
      ctx.fill();

      ctx.strokeStyle = '#ff2200';
      ctx.lineWidth = 2;
      ctx.stroke();

      // Labels
      ctx.fillStyle = '#e8e0d5';
      ctx.font = 'bold 12px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.fillText('x (position)', plotX + plotWidth / 2, height - 15);
      ctx.textAlign = 'right';
      ctx.save();
      ctx.translate(15, plotY + plotHeight / 2);
      ctx.rotate(-Math.PI / 2);
      ctx.fillText('v (velocity)', 0, 0);
      ctx.restore();

      // Axis ticks & labels
      ctx.fillStyle = '#888888';
      ctx.font = '10px "DM Mono"';
      ctx.textAlign = 'center';
      for (let i = -150; i <= 150; i += 75) {
        const x = plotX + plotWidth / 2 + (i / xMax) * (plotWidth / 2);
        ctx.fillText(i, x, plotY + plotHeight + 15);
      }

      ctx.textAlign = 'right';
      for (let i = -10; i <= 10; i += 5) {
        const y = plotY + plotHeight / 2 - (i / vMax) * (plotHeight / 2);
        ctx.fillText(i, plotX - 10, y + 4);
      }
    }

    // ============================================
    // Update UI
    // ============================================
    function updateStats() {
      const omega0 = calculateOmega0();
      const gamma = calculateGamma();
      const omegad = calculateOmegaD();
      const regime = getRegime();
      const period = omegad > 0 ? (2 * Math.PI) / omegad : 0;

      document.getElementById('k-value').textContent = STATE.k.toFixed(2);
      document.getElementById('m-value').textContent = STATE.m.toFixed(2);
      document.getElementById('b-value').textContent = STATE.b.toFixed(2);
      document.getElementById('A-value').textContent = Math.round(STATE.initialA);
      document.getElementById('v0-value').textContent = STATE.initialV0.toFixed(1);
      document.getElementById('ts-value').textContent = STATE.timeScale.toFixed(1);

      document.getElementById('omega0').textContent = omega0.toFixed(2);
      document.getElementById('omegad').textContent = omegad.toFixed(2);
      document.getElementById('period').textContent = period > 0 ? period.toFixed(2) : 'N/A';

      const regimeText = regime === 'underdamped' ? 'Underdamped' : regime === 'critical' ? 'Critically Damped' : 'Overdamped';
      const regimeBadge = document.getElementById('regime-badge');
      regimeBadge.className = `regime-badge regime-${regime}`;
      regimeBadge.textContent = regimeText;
      document.getElementById('regime-name').textContent = regimeText;

      document.getElementById('position').textContent = STATE.x.toFixed(1);
      document.getElementById('velocity').textContent = STATE.v.toFixed(2);

      const ke = 0.5 * STATE.m * STATE.v * STATE.v;
      const pe = 0.5 * STATE.k * STATE.x * STATE.x;
      const te = ke + pe;

      document.getElementById('ke').textContent = ke.toFixed(3);
      document.getElementById('pe').textContent = pe.toFixed(3);
      document.getElementById('te').textContent = te.toFixed(3);

      // Energy bars
      const maxEnergy = Math.max(te, 0.01);
      const kePercent = (ke / maxEnergy) * 100;
      const pePercent = (pe / maxEnergy) * 100;
      const tePercent = 100;

      document.getElementById('ke-bar').style.width = Math.min(kePercent, 100) + '%';
      document.getElementById('pe-bar').style.width = Math.min(pePercent, 100) + '%';
      document.getElementById('te-bar').style.width = tePercent + '%';

      document.getElementById('ke-bar-value').textContent = ke.toFixed(3);
      document.getElementById('pe-bar-value').textContent = pe.toFixed(3);
      document.getElementById('te-bar-value').textContent = te.toFixed(3);
    }

    // ============================================
    // Animation Loop
    // ============================================
    function animate() {
      updatePhysics(STATE.dt);
      updateStats();

      if (FLAGS.showPhaseSpace) {
        drawPhaseSpace(phaseCtx);
      } else {
        phaseCtx.fillStyle = '#111111';
        phaseCtx.fillRect(0, 0, phaseCanvas.width, phaseCanvas.height);
      }

      drawSpring(springCtx);

      requestAnimationFrame(animate);
    }

    // ============================================
    // Control Listeners
    // ============================================
    document.getElementById('spring-constant').addEventListener('input', (e) => {
      STATE.k = parseFloat(e.target.value);
      resetSimulation();
    });

    document.getElementById('mass').addEventListener('input', (e) => {
      STATE.m = parseFloat(e.target.value);
      resetSimulation();
    });

    document.getElementById('damping').addEventListener('input', (e) => {
      STATE.b = parseFloat(e.target.value);
    });

    document.getElementById('initial-displacement').addEventListener('input', (e) => {
      STATE.initialA = parseFloat(e.target.value);
      resetSimulation();
    });

    document.getElementById('initial-velocity').addEventListener('input', (e) => {
      STATE.initialV0 = parseFloat(e.target.value);
      resetSimulation();
    });

    document.getElementById('time-scale').addEventListener('input', (e) => {
      STATE.timeScale = parseFloat(e.target.value);
    });

    document.getElementById('play-btn').addEventListener('click', () => {
      STATE.running = true;
    });

    document.getElementById('pause-btn').addEventListener('click', () => {
      STATE.running = false;
    });

    document.getElementById('reset-btn').addEventListener('click', () => {
      resetSimulation();
    });

    document.getElementById('show-phase-space').addEventListener('change', (e) => {
      FLAGS.showPhaseSpace = e.target.checked;
    });

    document.getElementById('show-energy').addEventListener('change', (e) => {
      FLAGS.showEnergy = e.target.checked;
      document.getElementById('energy-section').style.display = e.target.checked ? 'block' : 'none';
    });

    document.getElementById('show-velocity').addEventListener('change', (e) => {
      FLAGS.showVelocity = e.target.checked;
    });

    document.getElementById('show-trail').addEventListener('change', (e) => {
      FLAGS.showTrail = e.target.checked;
    });

    // Drag to set initial position
    let dragging = false;
    springCanvas.addEventListener('mousedown', (e) => {
      if (!STATE.running) {
        dragging = true;
        const rect = springCanvas.getBoundingClientRect();
        const y = e.clientY - rect.top;
        const equilibriumY = 150;
        STATE.initialA = y - equilibriumY;
        STATE.x = STATE.initialA;
      }
    });

    document.addEventListener('mousemove', (e) => {
      if (dragging) {
        const rect = springCanvas.getBoundingClientRect();
        const y = e.clientY - rect.top;
        const equilibriumY = 150;
        STATE.initialA = Math.max(-150, Math.min(150, y - equilibriumY));
        STATE.x = STATE.initialA;
      }
    });

    document.addEventListener('mouseup', () => {
      dragging = false;
    });

    function resetSimulation() {
      STATE.x = STATE.initialA;
      STATE.v = STATE.initialV0;
      STATE.t = 0;
      phaseHistory = [];
      STATE.running = false;
      updateStats();
    }

    // ============================================
    // Initialize
    // ============================================
    resetSimulation();
    updateStats();
    animate();