Physics Simulation
GRAVITATIONAL WAVES
Interactive visualization of spacetime ripples from binary merger. Watch orbits shrink, see LIGO signals, and explore the physics of gravitational waves.
📡 Source Configuration
30 M☉
Solar masses (1 M☉ ≈ 1.989 × 10³¹ kg)
30 M☉
Solar masses
5000 km
Orbital distance (Schwarzschild radii)
2.0×
Time compression factor
Binary System
Top-down view
Spacetime Deformation
Test masses in GW field
LIGO Interferometer
Laser arm length difference
GW Strain Signal h(t)
Detected amplitude vs time (Chirp → Merger → Ringdown)
Live Statistics
Orbital Frequency
0.0 Hz
GW Frequency
0.0 Hz
Strain Amplitude
0.0 × 10⁻²¹
Separation
5000 km
Time to Merger
∞
Energy Radiated
0 M☉c²
Orbital Speed (M1)
0 km/s
Orbital Period
∞
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ============================================================================
// GRAVITATIONAL WAVES SIMULATION
// ============================================================================
const state = {
mass1: 30, // Solar masses
mass2: 30,
separation: 5000, // km
speedFactor: 2,
polarization: 'plus',
displayOptions: {
orbit: true,
strain: true,
testmass: true,
ligo: true
},
isRunning: true,
currentPhase: 0,
mergerTime: 0,
strainHistory: [],
frequencyHistory: [],
energyRadiated: 0,
initialEnergy: 0,
objectType1: 'bh',
objectType2: 'bh'
};
// Physical constants (in SI-ish units)
const G = 6.674e-11; // Gravitational constant (m^3 kg^-1 s^-2)
const c = 2.998e8; // Speed of light (m/s)
const M_SUN = 1.989e30; // Solar mass (kg)
const AU = 1.496e11; // Astronomical unit (m)
// Convert km to meters
function kmToM(km) {
return km * 1000;
}
// Calculate orbital frequency (Hz) from separation and masses
function getOrbitalFrequency(m1, m2, sep) {
const M = (m1 + m2) * M_SUN;
const r = kmToM(sep);
const omega = Math.sqrt(G * M / (r * r * r));
return omega / (2 * Math.PI);
}
// Kepler's third law: calculate separation from period
function getSeparationFromFreq(m1, m2, freq) {
const M = (m1 + m2) * M_SUN;
const omega = 2 * Math.PI * freq;
const r = Math.cbrt(G * M / (omega * omega));
return r / 1000; // meters to km
}
// Calculate time to merger using quadrupole radiation formula
function getTimeToMerger(m1, m2, sep) {
const c5 = Math.pow(c, 5);
const G3 = G * G * G;
const m1kg = m1 * M_SUN;
const m2kg = m2 * M_SUN;
const M = m1kg + m2kg;
const mu = (m1kg * m2kg) / M;
const r = kmToM(sep);
const Rsch = 2 * G * M / (c * c);
if (r <= 6 * Rsch) return 0; // Already at merger
const factor = (12 * c5) / (64 * G3 * m1kg * m2kg * (m1kg + m2kg));
const t = factor * (r * r * r * r * r) / (c * c * c * c);
return Math.max(0, t); // seconds
}
// Power radiated (in watts)
function getRadiatedPower(m1, m2, sep) {
const m1kg = m1 * M_SUN;
const m2kg = m2 * M_SUN;
const M = m1kg + m2kg;
const r = kmToM(sep);
const numerator = 32 * Math.pow(G, 4) * m1kg * m1kg * m2kg * m2kg * M;
const denominator = 5 * Math.pow(c, 5) * Math.pow(r, 5);
return numerator / denominator;
}
// Strain amplitude at distance
function getStrainAmplitude(m1, m2, freq, distance = 1e26) {
const M = (m1 + m2) * M_SUN;
const Mc = Math.pow((m1 * m2 * M_SUN * M_SUN) / (M * M), 1/5);
const omega = 2 * Math.PI * freq;
const h = (4/3) * Math.pow(G * Mc / (c * c), 5/3) * Math.pow(omega / c, 2/3) / distance;
return Math.abs(h);
}
// Update simulation state
function updateSimulation(deltaTime) {
if (!state.isRunning) return;
const orbFreq = getOrbitalFrequency(state.mass1, state.mass2, state.separation);
const timeToMerge = getTimeToMerger(state.mass1, state.mass2, state.separation);
const gwFreq = orbFreq * 2;
const power = getRadiatedPower(state.mass1, state.mass2, state.separation);
// Update energy radiated (integrate power)
state.energyRadiated += power * deltaTime / (M_SUN * c * c);
// Shrink orbit due to energy loss
if (timeToMerge > 0) {
const freqDerivative = Math.pow(gwFreq, 11/3) * (96 * Math.pow(Math.PI, 8/3)) / 5;
const newGwFreq = gwFreq + freqDerivative * deltaTime * state.speedFactor;
const newSeparation = getSeparationFromFreq(state.mass1, state.mass2, newGwFreq / 2);
state.separation = Math.max(state.separation * 1.0001, newSeparation); // Prevent division by zero
}
// Update phase for animation
state.currentPhase += orbFreq * 2 * Math.PI * deltaTime * state.speedFactor;
state.currentPhase %= 2 * Math.PI;
// Track strain history
const strain = getStrainAmplitude(state.mass1, state.mass2, gwFreq) * 1e21;
state.strainHistory.push({ time: state.strainHistory.length, amplitude: strain });
if (state.strainHistory.length > 1000) {
state.strainHistory.shift();
}
}
// ============================================================================
// CANVAS RENDERING
// ============================================================================
function renderOrbit() {
const canvas = document.getElementById('orbitCanvas');
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const centerX = w / 2;
const centerY = h / 2;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// Calculate positions
const totalMass = state.mass1 + state.mass2;
const r1 = (state.separation * state.mass2) / totalMass;
const r2 = (state.separation * state.mass1) / totalMass;
const scale = (w / 2 - 50) / state.separation;
const x1 = centerX + r1 * scale * Math.cos(state.currentPhase);
const y1 = centerY + r1 * scale * Math.sin(state.currentPhase);
const x2 = centerX - r2 * scale * Math.cos(state.currentPhase);
const y2 = centerY - r2 * scale * Math.sin(state.currentPhase);
// Draw orbit
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(centerX, centerY, r1 * scale, 0, Math.PI * 2);
ctx.stroke();
// Draw barycenter
ctx.fillStyle = '#333333';
ctx.beginPath();
ctx.arc(centerX, centerY, 3, 0, Math.PI * 2);
ctx.fill();
// Draw objects
const size1 = Math.sqrt(state.mass1) * 1.5;
const size2 = Math.sqrt(state.mass2) * 1.5;
if (state.objectType1 === 'bh') {
// Black hole: dark with ring
ctx.fillStyle = '#111111';
ctx.beginPath();
ctx.arc(x1, y1, size1, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x1, y1, size1, 0, Math.PI * 2);
ctx.stroke();
} else {
// Neutron star: bright blue
ctx.fillStyle = '#00c8ff';
ctx.beginPath();
ctx.arc(x1, y1, size1, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = 'rgba(0, 200, 255, 0.8)';
ctx.shadowBlur = 8;
}
if (state.objectType2 === 'bh') {
ctx.fillStyle = '#111111';
ctx.beginPath();
ctx.arc(x2, y2, size2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x2, y2, size2, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.fillStyle = '#00c8ff';
ctx.beginPath();
ctx.arc(x2, y2, size2, 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
// Labels
ctx.fillStyle = '#555555';
ctx.font = '12px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.fillText(`${state.mass1} M☉`, x1, y1 + size1 + 20);
ctx.fillText(`${state.mass2} M☉`, x2, y2 + size2 + 20);
}
function renderTestMass() {
const canvas = document.getElementById('testmassCanvas');
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const centerX = w / 2;
const centerY = h / 2;
const radius = 100;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// Calculate strain
const orbFreq = getOrbitalFrequency(state.mass1, state.mass2, state.separation);
const gwFreq = orbFreq * 2;
const strain = getStrainAmplitude(state.mass1, state.mass2, gwFreq);
// Determine polarization
let hPlus = 0, hCross = 0;
if (state.polarization === 'plus' || state.polarization === 'combined') {
hPlus = strain * Math.cos(state.currentPhase * 2);
}
if (state.polarization === 'cross' || state.polarization === 'combined') {
hCross = strain * Math.sin(state.currentPhase * 2);
}
// Draw test mass ring
const numMasses = 12;
const positions = [];
for (let i = 0; i < numMasses; i++) {
const angle = (i / numMasses) * Math.PI * 2;
const baseX = centerX + radius * Math.cos(angle);
const baseY = centerY + radius * Math.sin(angle);
// Apply strain deformation
let x = baseX;
let y = baseY;
if (state.polarization === 'plus' || state.polarization === 'combined') {
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
const h = hPlus;
x += h * radius * cosAngle * cosAngle;
y -= h * radius * sinAngle * sinAngle;
}
if (state.polarization === 'cross' || state.polarization === 'combined') {
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
const h = hCross;
x += h * radius * cosAngle * sinAngle;
y += h * radius * cosAngle * sinAngle;
}
positions.push({ x, y, angle });
}
// Draw connecting line
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(positions[0].x, positions[0].y);
for (let i = 1; i < positions.length; i++) {
ctx.lineTo(positions[i].x, positions[i].y);
}
ctx.lineTo(positions[0].x, positions[0].y);
ctx.stroke();
// Draw test masses
positions.forEach(pos => {
ctx.fillStyle = '#00c896';
ctx.shadowColor = 'rgba(0, 200, 150, 0.8)';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 4, 0, Math.PI * 2);
ctx.fill();
});
ctx.shadowBlur = 0;
// Draw center
ctx.fillStyle = '#333333';
ctx.beginPath();
ctx.arc(centerX, centerY, 2, 0, Math.PI * 2);
ctx.fill();
// Draw axis labels
ctx.fillStyle = '#555555';
ctx.font = '10px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.fillText('N', centerX, centerY - radius - 15);
ctx.fillText('S', centerX, centerY + radius + 15);
ctx.fillText('E', centerX + radius + 15, centerY + 5);
ctx.fillText('W', centerX - radius - 15, centerY + 5);
// Draw polarization indicator
ctx.fillStyle = '#ff2200';
ctx.font = 'bold 12px "Bebas Neue", sans-serif';
ctx.textAlign = 'left';
const polLabel = state.polarization === 'plus' ? 'h₊' : state.polarization === 'cross' ? 'h×' : 'h₊ + h×';
ctx.fillText(polLabel, 20, 25);
}
function renderLIGO() {
const canvas = document.getElementById('ligoCanvas');
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const armLength = 150;
// Calculate strain
const orbFreq = getOrbitalFrequency(state.mass1, state.mass2, state.separation);
const gwFreq = orbFreq * 2;
const strain = getStrainAmplitude(state.mass1, state.mass2, gwFreq) * Math.cos(state.currentPhase * 2);
// Draw beam splitter
ctx.fillStyle = '#333333';
ctx.beginPath();
ctx.arc(centerX, centerY, 8, 0, Math.PI * 2);
ctx.fill();
// Draw arms with deformation
const stretchX = strain * armLength;
const compressY = -strain * armLength;
// X-arm (horizontal)
ctx.strokeStyle = '#ff2200';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(centerX - armLength, centerY);
ctx.lineTo(centerX + armLength + stretchX, centerY);
ctx.stroke();
// Y-arm (vertical)
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(centerX, centerY - armLength);
ctx.lineTo(centerX, centerY + armLength + compressY);
ctx.stroke();
// Draw mirrors
const mirrorSize = 6;
ctx.fillStyle = '#ff2200';
ctx.fillRect(centerX + armLength + stretchX - mirrorSize, centerY - mirrorSize, mirrorSize * 2, mirrorSize * 2);
ctx.fillStyle = '#00c896';
ctx.fillRect(centerX - mirrorSize, centerY + armLength + compressY - mirrorSize, mirrorSize * 2, mirrorSize * 2);
// Draw photodiode
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(centerX - armLength - 20, centerY, 5, 0, Math.PI * 2);
ctx.fill();
// Labels
ctx.fillStyle = '#555555';
ctx.font = '11px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.fillText('Splitter', centerX, centerY + 20);
ctx.fillText('Photodiode', centerX - armLength - 20, centerY - 15);
// Show path difference
const pathDiff = 2 * armLength * strain;
ctx.fillStyle = '#ff2200';
ctx.font = 'bold 12px "DM Mono", monospace';
ctx.textAlign = 'left';
ctx.fillText(`ΔL = ${(pathDiff * 1e12).toFixed(2)} pm`, 20, 25);
}
function renderStrain() {
const canvas = document.getElementById('strainCanvas');
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const padding = 40;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
if (state.strainHistory.length < 2) return;
// Draw grid
ctx.strokeStyle = '#1e1e1e';
ctx.lineWidth = 1;
ctx.font = '10px "DM Mono", monospace';
ctx.fillStyle = '#555555';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const y = padding + (h - padding * 2) * i / 4;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(w - 10, y);
ctx.stroke();
const val = (1 - i * 0.25) * 2;
ctx.fillText(val.toFixed(1), padding - 10, y + 3);
}
// Draw axes
ctx.strokeStyle = '#333333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padding, h - padding);
ctx.lineTo(w - 10, h - padding);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, h - padding);
ctx.stroke();
// Draw strain curve
ctx.strokeStyle = '#00c896';
ctx.lineWidth = 2;
ctx.beginPath();
const xScale = (w - padding - 10) / Math.max(1, state.strainHistory.length);
const yScale = (h - padding * 2) / 4;
state.strainHistory.forEach((point, idx) => {
const x = padding + idx * xScale;
const y = h - padding - (point.amplitude + 1) * yScale;
if (idx === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Labels
ctx.fillStyle = '#555555';
ctx.font = '11px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.fillText('Time →', w / 2, h - 10);
ctx.save();
ctx.translate(10, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('h (10⁻²¹)', 0, 0);
ctx.restore();
}
function render() {
renderOrbit();
renderTestMass();
renderLIGO();
renderStrain();
}
// ============================================================================
// UI UPDATES
// ============================================================================
function updateStats() {
const orbFreq = getOrbitalFrequency(state.mass1, state.mass2, state.separation);
const gwFreq = orbFreq * 2;
const strain = getStrainAmplitude(state.mass1, state.mass2, gwFreq) * 1e21;
const timeToMerge = getTimeToMerger(state.mass1, state.mass2, state.separation);
// Calculate orbital speed
const M = (state.mass1 + state.mass2) * M_SUN;
const r = kmToM(state.separation);
const orbSpeed = Math.sqrt(G * M / r) / 1000; // km/s
// Calculate period
const period = 1 / orbFreq;
document.getElementById('stat-orb-freq').textContent = orbFreq.toFixed(2) + ' Hz';
document.getElementById('stat-gw-freq').textContent = gwFreq.toFixed(2) + ' Hz';
document.getElementById('stat-strain').textContent = strain.toFixed(2);
document.getElementById('stat-sep').textContent = state.separation.toFixed(0) + ' km';
document.getElementById('stat-merger-time').textContent = timeToMerge > 0 ? (timeToMerge / 1000).toFixed(1) + ' ks' : 'Now!';
document.getElementById('stat-energy').textContent = state.energyRadiated.toFixed(3);
document.getElementById('stat-vel').textContent = orbSpeed.toFixed(0);
document.getElementById('stat-period').textContent = period.toFixed(3) + ' s';
}
// ============================================================================
// EVENT LISTENERS
// ============================================================================
document.getElementById('mass1').addEventListener('input', (e) => {
state.mass1 = parseFloat(e.target.value);
document.getElementById('mass1-val').textContent = state.mass1 + ' M☉';
updateStats();
});
document.getElementById('mass2').addEventListener('input', (e) => {
state.mass2 = parseFloat(e.target.value);
document.getElementById('mass2-val').textContent = state.mass2 + ' M☉';
updateStats();
});
document.getElementById('separation').addEventListener('input', (e) => {
state.separation = parseFloat(e.target.value);
document.getElementById('separation-val').textContent = state.separation.toFixed(0) + ' km';
updateStats();
});
document.getElementById('speed').addEventListener('input', (e) => {
state.speedFactor = parseFloat(e.target.value);
document.getElementById('speed-val').textContent = state.speedFactor.toFixed(1) + '×';
});
document.querySelectorAll('[data-polarization]').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('[data-polarization]').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
state.polarization = e.target.dataset.polarization;
});
});
document.querySelectorAll('[data-display]').forEach(btn => {
btn.addEventListener('click', (e) => {
const option = e.target.dataset.display;
state.displayOptions[option] = !state.displayOptions[option];
e.target.classList.toggle('active');
});
});
document.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
const preset = e.target.dataset.preset;
state.strainHistory = [];
state.energyRadiated = 0;
switch (preset) {
case 'gw150914':
state.mass1 = 36;
state.mass2 = 29;
state.objectType1 = 'bh';
state.objectType2 = 'bh';
state.separation = 3000;
break;
case 'gw170817':
state.mass1 = 1.4;
state.mass2 = 1.27;
state.objectType1 = 'ns';
state.objectType2 = 'ns';
state.separation = 2000;
break;
case 'mixed':
state.mass1 = 10;
state.mass2 = 1.4;
state.objectType1 = 'bh';
state.objectType2 = 'ns';
state.separation = 2500;
break;
case 'custom':
state.mass1 = 30;
state.mass2 = 30;
state.objectType1 = 'bh';
state.objectType2 = 'bh';
state.separation = 5000;
break;
}
document.getElementById('mass1').value = state.mass1;
document.getElementById('mass2').value = state.mass2;
document.getElementById('separation').value = state.separation;
document.getElementById('mass1-val').textContent = state.mass1 + ' M☉';
document.getElementById('mass2-val').textContent = state.mass2 + ' M☉';
document.getElementById('separation-val').textContent = state.separation + ' km';
updateStats();
});
});
document.getElementById('reset-btn').addEventListener('click', () => {
state.currentPhase = 0;
state.strainHistory = [];
state.energyRadiated = 0;
updateStats();
});
document.getElementById('pause-btn').addEventListener('click', (e) => {
state.isRunning = !state.isRunning;
e.target.textContent = state.isRunning ? 'Pause' : 'Resume';
e.target.style.backgroundColor = state.isRunning ? '#161616' : '#ff2200';
e.target.style.color = state.isRunning ? '#e8e0d5' : '#0a0a0a';
});
// ============================================================================
// ANIMATION LOOP
// ============================================================================
let lastTime = Date.now();
function animate() {
const currentTime = Date.now();
const deltaTime = (currentTime - lastTime) / 1000; // Convert to seconds
lastTime = currentTime;
updateSimulation(Math.min(deltaTime, 0.1)); // Cap delta time
updateStats();
render();
requestAnimationFrame(animate);
}
// Initialize
updateStats();
render();
animate();