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