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