Physics • Education

Orbital Mechanics

Interactive Kepler orbital mechanics simulator. Explore elliptical orbits, eccentricity, and the laws of planetary motion with real-time physics.

Simulation

Yellow/orange glowing body = central star. Colored bodies = orbiting planets. The dotted line shows the orbital path, foci mark the orbit geometry, and the blue arrow shows velocity direction.

Controls

Orbital Parameters

Semi-Major Axis (a)
150 km
Eccentricity (e)
0.50
Orbital Period (T)
1.00 year
Current Velocity
29.8 km/s
Distance from Focus
100 km
Specific Orbital Energy
-0.50 km²/s²
Periapsis (r_p)
75 km
Apoapsis (r_a)
225 km
All values update in real-time. Vis-viva equation: v² = GM(2/r − 1/a). Period: T² = 4π²a³/GM (Kepler's Third Law).
Developer Reference

Core Algorithm & Standalone Script

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

// Constants (all in SI-ish units for simplicity)
    const G = 6.674e-11; // Gravitational constant (scaled for visualization)
    const AU_KM = 1.496e8; // 1 AU in km (scaled down for canvas)
    const EARTH_MASS = 5.972e24;
    const SOLAR_MASS = 1.989e30;
    const SPEED_OF_LIGHT = 3e8;

    // Scale factors for visualization
    const SCALE_POS = 1e-11; // Scale positions to canvas
    const SCALE_VEL = 1e-3; // Scale velocities for arrows
    const TIME_STEP = 0.01; // Years per frame (scaled)

    // State
    let state = {
      mode: 'single',
      eccentricity: 0.5,
      semiMajor: 150, // km (relative units)
      mass: 1.0, // Solar masses
      speedMult: 1.0,
      isRunning: false,
      showOrbitPath: true,
      showFoci: true,
      showVelocity: true,
      showSweepArea: false,
      showLabels: true,
      showStarfield: false,
      showGrid: false,
      time: 0,
      bodies: []
    };

    // Canvas setup
    const canvas = document.getElementById('orbitCanvas');
    const ctx = canvas.getContext('2d');

    function resizeCanvas() {
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width;
      canvas.height = rect.height;
    }
    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    // Initialize bodies for current mode
    function initializeBodies() {
      state.bodies = [];

      if (state.mode === 'single') {
        const body = {
          x: 0, y: 0,
          vx: 0, vy: 0,
          mass: 1.0,
          radius: 8,
          color: '#FFD700',
          glowing: true,
          trails: []
        };
        state.bodies.push(body);

        // Planet
        const planet = createPlanet(state.semiMajor, state.eccentricity, 0.1, 4, '#3498db');
        state.bodies.push(planet);
      } else if (state.mode === 'solar') {
        const sun = {
          x: 0, y: 0,
          vx: 0, vy: 0,
          mass: 1.0,
          radius: 10,
          color: '#FFD700',
          glowing: true,
          trails: []
        };
        state.bodies.push(sun);

        // Mercury, Venus, Earth, Mars
        const planets = [
          { a: 60, e: 0.2, color: '#8B7355', r: 2 },
          { a: 100, e: 0.01, color: '#FFA500', r: 3 },
          { a: 150, e: 0.02, color: '#3498db', r: 3.5 },
          { a: 200, e: 0.1, color: '#E27B58', r: 2.5 }
        ];

        planets.forEach((p, i) => {
          const planet = createPlanet(p.a, p.e, 0.5 + i * 0.2, p.r, p.color);
          state.bodies.push(planet);
        });
      } else if (state.mode === 'binary') {
        const star1 = {
          x: -50, y: 0,
          vx: 0, vy: 20,
          mass: 0.5,
          radius: 6,
          color: '#FFD700',
          glowing: true,
          trails: []
        };
        const star2 = {
          x: 50, y: 0,
          vx: 0, vy: -20,
          mass: 0.5,
          radius: 6,
          color: '#FF6347',
          glowing: true,
          trails: []
        };
        state.bodies.push(star1, star2);
      } else if (state.mode === 'transfer') {
        const sun = {
          x: 0, y: 0,
          vx: 0, vy: 0,
          mass: 1.0,
          radius: 10,
          color: '#FFD700',
          glowing: true,
          trails: []
        };
        state.bodies.push(sun);

        // Circular orbit 1
        const p1 = createPlanet(100, 0, 0, 3.5, '#3498db');
        state.bodies.push(p1);

        // Circular orbit 2
        const p2 = createPlanet(200, 0, Math.PI, 3.5, '#E27B58');
        state.bodies.push(p2);

        // Transfer orbit (Hohmann)
        const p3 = createPlanet(150, 0.333, 0, 2.5, '#00FF00');
        p3.isTransfer = true;
        state.bodies.push(p3);
      }

      state.time = 0;
    }

    function createPlanet(a, e, phase, radius, color) {
      const r_p = a * (1 - e);
      const x = r_p * Math.cos(phase);
      const y = r_p * Math.sin(phase);

      // Vis-viva: v = sqrt(GM(2/r - 1/a))
      const r = Math.sqrt(x * x + y * y);
      const vMagnitude = Math.sqrt(state.mass * (2 / r - 1 / a));

      // Velocity perpendicular to radius
      const vx = -vMagnitude * Math.sin(phase) * state.speedMult;
      const vy = vMagnitude * Math.cos(phase) * state.speedMult;

      return {
        x, y, vx, vy,
        a, e, phase,
        mass: 0.01,
        radius,
        color,
        glowing: false,
        trails: []
      };
    }

    // Physics integration (Leapfrog)
    function updatePhysics() {
      const n = state.bodies.length;

      // Half-step velocity
      for (let i = 0; i < n; i++) {
        const body = state.bodies[i];
        const acc = getAcceleration(i);
        body.vx += acc.x * TIME_STEP * 0.5 * state.speedMult;
        body.vy += acc.y * TIME_STEP * 0.5 * state.speedMult;
      }

      // Full-step position
      for (let i = 0; i < n; i++) {
        const body = state.bodies[i];
        body.x += body.vx * TIME_STEP * state.speedMult;
        body.y += body.vy * TIME_STEP * state.speedMult;

        // Trail
        body.trails.push({x: body.x, y: body.y});
        if (body.trails.length > 500) body.trails.shift();
      }

      // Half-step velocity again
      for (let i = 0; i < n; i++) {
        const body = state.bodies[i];
        const acc = getAcceleration(i);
        body.vx += acc.x * TIME_STEP * 0.5 * state.speedMult;
        body.vy += acc.y * TIME_STEP * 0.5 * state.speedMult;
      }

      state.time += TIME_STEP;
    }

    function getAcceleration(bodyIndex) {
      const body = state.bodies[bodyIndex];
      let ax = 0, ay = 0;

      for (let i = 0; i < state.bodies.length; i++) {
        if (i === bodyIndex) continue;

        const other = state.bodies[i];
        const dx = other.x - body.x;
        const dy = other.y - body.y;
        const r = Math.sqrt(dx * dx + dy * dy);

        if (r > 0.1) {
          const a = (state.mass * other.mass) / (r * r * r);
          ax += a * dx;
          ay += a * dy;
        }
      }

      return { x: ax, y: ay };
    }

    // Rendering
    function drawStarfield() {
      if (!state.showStarfield) return;

      ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
      for (let i = 0; i < 200; i++) {
        const x = (Math.sin(i * 12.9898) * 43758.5453) % canvas.width;
        const y = (Math.cos(i * 78.233) * 43758.5453) % canvas.height;
        const size = Math.random() * 0.5;
        ctx.fillRect(x, y, size, size);
      }
    }

    function drawGrid() {
      if (!state.showGrid) return;

      ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
      ctx.lineWidth = 1;
      const spacing = 50;

      const centerX = canvas.width / 2;
      const centerY = canvas.height / 2;

      for (let x = centerX % spacing; x < canvas.width; x += spacing) {
        ctx.beginPath();
        ctx.moveTo(x, 0);
        ctx.lineTo(x, canvas.height);
        ctx.stroke();
      }

      for (let y = centerY % spacing; y < canvas.height; y += spacing) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(canvas.width, y);
        ctx.stroke();
      }
    }

    function canvasCoords(x, y) {
      return {
        x: canvas.width / 2 + x / SCALE_POS,
        y: canvas.height / 2 - y / SCALE_POS
      };
    }

    function drawOrbitPath(body) {
      if (!state.showOrbitPath || !body.a) return;

      const a = body.a;
      const e = body.e;
      const centerX = canvas.width / 2 - (a * e) / SCALE_POS;
      const centerY = canvas.height / 2;

      ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
      ctx.lineWidth = 1;
      ctx.setLineDash([5, 5]);

      ctx.beginPath();
      let firstPoint = true;
      for (let theta = 0; theta < Math.PI * 2; theta += 0.05) {
        const r = (a * (1 - e * e)) / (1 + e * Math.cos(theta));
        const px = centerX + (r * Math.cos(theta)) / SCALE_POS;
        const py = centerY + (r * Math.sin(theta)) / SCALE_POS;

        if (firstPoint) {
          ctx.moveTo(px, py);
          firstPoint = false;
        } else {
          ctx.lineTo(px, py);
        }
      }
      ctx.closePath();
      ctx.stroke();
      ctx.setLineDash([]);

      // Draw foci
      if (state.showFoci) {
        const focusX = (a * e) / SCALE_POS;
        ctx.fillStyle = 'rgba(255, 200, 100, 0.5)';
        ctx.beginPath();
        ctx.arc(centerX - focusX, centerY, 3, 0, Math.PI * 2);
        ctx.fill();
        ctx.beginPath();
        ctx.arc(centerX + focusX, centerY, 3, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    function drawBodies() {
      state.bodies.forEach((body, i) => {
        // Trail
        if (body.trails.length > 1) {
          ctx.strokeStyle = body.color.replace(')', ', 0.3)').replace('rgb', 'rgba');
          ctx.lineWidth = 1;
          ctx.beginPath();
          const first = canvasCoords(body.trails[0].x, body.trails[0].y);
          ctx.moveTo(first.x, first.y);

          for (let j = 1; j < body.trails.length; j++) {
            const pt = canvasCoords(body.trails[j].x, body.trails[j].y);
            ctx.lineTo(pt.x, pt.y);
          }
          ctx.stroke();
        }

        // Draw orbit path for single planet in single mode
        if (state.mode === 'single' && i === 1) {
          drawOrbitPath(body);
        } else if (state.mode === 'solar' && i > 0) {
          drawOrbitPath(body);
        } else if (state.mode === 'transfer' && i > 0) {
          drawOrbitPath(body);
        }

        // Body
        const pos = canvasCoords(body.x, body.y);

        if (body.glowing) {
          const grad = ctx.createRadialGradient(pos.x, pos.y, body.radius * 0.5, pos.x, pos.y, body.radius * 2);
          grad.addColorStop(0, body.color);
          grad.addColorStop(1, 'rgba(255, 215, 0, 0)');
          ctx.fillStyle = grad;
          ctx.beginPath();
          ctx.arc(pos.x, pos.y, body.radius * 2, 0, Math.PI * 2);
          ctx.fill();
        }

        ctx.fillStyle = body.color;
        ctx.beginPath();
        ctx.arc(pos.x, pos.y, body.radius, 0, Math.PI * 2);
        ctx.fill();

        // Velocity vector
        if (state.showVelocity && (body.mass < 0.5 || state.mode === 'binary')) {
          const vLen = Math.sqrt(body.vx * body.vx + body.vy * body.vy);
          if (vLen > 0.01) {
            const arrowLen = 30;
            const ex = pos.x + (body.vx / vLen) * arrowLen;
            const ey = pos.y - (body.vy / vLen) * arrowLen;

            ctx.strokeStyle = '#00c896';
            ctx.lineWidth = 2;
            ctx.beginPath();
            ctx.moveTo(pos.x, pos.y);
            ctx.lineTo(ex, ey);
            ctx.stroke();

            // Arrow head
            const headLen = 6;
            const angle = Math.atan2(ey - pos.y, ex - pos.x);
            ctx.beginPath();
            ctx.moveTo(ex, ey);
            ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
            ctx.moveTo(ex, ey);
            ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
            ctx.stroke();
          }
        }

        // Label
        if (state.showLabels && body.a) {
          ctx.fillStyle = '#e8e0d5';
          ctx.font = 'bold 11px "DM Mono"';
          ctx.textAlign = 'left';
          const labels = ['Sun', 'Mercury', 'Venus', 'Earth', 'Mars', 'Star1', 'Star2', 'Planet1', 'Planet2', 'Transfer'];
          ctx.fillText(labels[i] || `Body${i}`, pos.x + 15, pos.y - 5);
        }
      });
    }

    function render() {
      ctx.fillStyle = '#000000';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      drawStarfield();
      drawGrid();
      drawBodies();
    }

    // Update stats
    function updateStats() {
      if (!state.bodies[1]) return;

      const body = state.bodies[1];
      if (!body.a) {
        document.getElementById('statA').textContent = '—';
        document.getElementById('statE').textContent = '—';
        document.getElementById('statT').textContent = '—';
        document.getElementById('statV').textContent = '—';
        document.getElementById('statR').textContent = '—';
        document.getElementById('statEnergy').textContent = '—';
        document.getElementById('statPeri').textContent = '—';
        document.getElementById('statApo').textContent = '—';
        return;
      }

      const a = body.a;
      const e = body.e;
      const r = Math.sqrt(body.x * body.x + body.y * body.y);
      const v = Math.sqrt(body.vx * body.vx + body.vy * body.vy);
      const T = 2 * Math.PI * Math.sqrt(a * a * a / state.mass);
      const energy = v * v / 2 - state.mass / r;
      const r_p = a * (1 - e);
      const r_a = a * (1 + e);

      document.getElementById('statA').textContent = `${a.toFixed(1)} km`;
      document.getElementById('statE').textContent = e.toFixed(3);
      document.getElementById('statT').textContent = `${(T / 1).toFixed(2)} year`;
      document.getElementById('statV').textContent = `${v.toFixed(2)} km/s`;
      document.getElementById('statR').textContent = `${r.toFixed(1)} km`;
      document.getElementById('statEnergy').textContent = `${energy.toFixed(3)} km²/s²`;
      document.getElementById('statPeri').textContent = `${r_p.toFixed(1)} km`;
      document.getElementById('statApo').textContent = `${r_a.toFixed(1)} km`;
    }

    // Control handlers
    document.getElementById('eccentricity').addEventListener('input', (e) => {
      state.eccentricity = parseFloat(e.target.value);
      document.getElementById('eccentricityValue').textContent = state.eccentricity.toFixed(2);
      if (state.mode === 'single') initializeBodies();
    });

    document.getElementById('semiMajor').addEventListener('input', (e) => {
      state.semiMajor = parseFloat(e.target.value);
      document.getElementById('semiMajorValue').textContent = state.semiMajor.toFixed(0);
      if (state.mode === 'single') initializeBodies();
    });

    document.getElementById('mass').addEventListener('input', (e) => {
      state.mass = parseFloat(e.target.value);
      document.getElementById('massValue').textContent = state.mass.toFixed(1);
      initializeBodies();
    });

    document.getElementById('speedMult').addEventListener('input', (e) => {
      state.speedMult = parseFloat(e.target.value);
      document.getElementById('speedValue').textContent = state.speedMult.toFixed(1) + 'x';
    });

    document.querySelectorAll('.mode-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        state.mode = btn.dataset.mode;
        initializeBodies();
      });
    });

    document.querySelectorAll('.toggle-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        btn.classList.toggle('active');
        const key = 'show' + btn.dataset.toggle.charAt(0).toUpperCase() + btn.dataset.toggle.slice(1);
        state[key] = !state[key];
      });
    });

    document.querySelectorAll('.preset-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        const preset = btn.dataset.preset;
        if (preset === 'circle') {
          state.eccentricity = 0;
        } else if (preset === 'ellipse') {
          state.eccentricity = 0.5;
        } else if (preset === 'comet') {
          state.eccentricity = 0.9;
        }
        document.getElementById('eccentricity').value = state.eccentricity;
        document.getElementById('eccentricityValue').textContent = state.eccentricity.toFixed(2);
        if (state.mode === 'single') initializeBodies();
      });
    });

    document.getElementById('pausePlayBtn').addEventListener('click', (e) => {
      state.isRunning = !state.isRunning;
      e.target.textContent = state.isRunning ? 'Pause' : 'Play';
      e.target.classList.toggle('active');
    });

    // Animation loop
    function animate() {
      if (state.isRunning) {
        updatePhysics();
      }
      updateStats();
      render();
      requestAnimationFrame(animate);
    }

    // Initialize
    initializeBodies();
    animate();