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