The source code of the two-body problem simulation

This is the complete source code of the two-body problem simulator. Feel free to use it on any web site.

<!--

Two-body problem simulator

https://evgenii.com/blog/two-body-problem-simulator/

You can copy/paste this code into an HTML file (a file with a name that ends with .html). Then this file can be opened locally on your computer or you can put it on your web site. Note that the code uses images loaded from https://evgenii.com web site. You will need to host these images if you want to make sure the simulation always works and is not dependent on evgenii.com web site.

License: Public Domain

Credits
=============

1. This work is based on code and lectures by Dr Rosemary Mardling from Monash University.

2. "The Blue Marble" By  NASA/Apollo 17 crew; taken by either Harrison Schmitt or Ron Evans. Sources: http://www.nasa.gov/images/content/115334main_image_feature_329_ys_full.jpg, https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg

3. "The Sun photographed at 304 angstroms" by NASA/SDO (AIA). Sources: http://sdo.gsfc.nasa.gov/assets/img/browse/2010/08/19/20100819_003221_4096_0304.jpg, https://commons.wikimedia.org/wiki/File:The_Sun_by_the_Atmospheric_Imaging_Assembly_of_NASA%27s_Solar_Dynamics_Observatory_-_20100819.jpg

-->

<style>

/*
  Layout
  --------
*/

.EarthOrbitSimulation-hasTopMarginNormal {
  margin-top: 15px;
}

.EarthOrbitSimulation-hasNegativeBottomMarginNormal {
  margin-bottom: -10px;
}

/*
  Elements
  --------
*/

.EarthOrbitSimulation-alert {
  color: red;
  border: 1px solid red;
  background: #ffeeee;
  padding: 5px;
}

.EarthOrbitSimulation-container {
  background-color: #000000;
  position: relative;
  height: 400px;
  background-image: url("https://evgenii.com/image/blog/2018-08-17-two-body-problem-simulator/starry_night.png");
  background-position: center bottom;
  background-repeat: repeat;
  background-size: 874px 260px;
}

.EarthOrbitSimulation-isTextCentered { text-align: center; }
.EarthOrbitSimulation-isHiddenBlock { display: none; }


.EarthOrbitSimulation-centerOfMass {
  position: absolute;
  width: 11px;
  top: 50%;
  left: 50%;
  margin-left: -5px;
  margin-top: -5px;
  z-index: 998;
}

.EarthOrbitSimulation-earth {
  position: absolute;
  width: 60px;
  top: -1000px;
  -webkit-animation:spin .1s linear infinite;
  -moz-animation:spin .1s linear infinite;
  animation:spin .10s linear infinite;
  z-index: 1000;
}

.EarthOrbitSimulation-sun {
  position: absolute;
  width: 60px;
  top: -1000px;
  -webkit-animation:spin .5s linear infinite;
  -moz-animation:spin .5s linear infinite;
  animation:spin .5s linear infinite;
  z-index: 1001;
}

@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }

.EarthOrbitSimulation-canvas { display: block; }

/* Prevent browser from showing selection when the element is touched */
.isUnselectable {
  -webkit-touch-callout: none;
  -webkit-user-select: none; /* Chrome/Safari */
  -moz-user-select: none; /* Firefox */
  -ms-user-select: none; /* IE10+ */
  -o-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0)
}

/*
  Hud display
  ---------
*/

.EarthOrbitSimulation-hudContainer {
  position: absolute;
  height: 100%;
  width: 100%;
  z-index: 1001;
  left: 0;
  top: 0;
}

.EarthOrbitSimulation-hudContainerChild {
  position: relative;
  width: 100%;
  height: 100%;
  max-width: 700px;
  margin-left: auto;
  margin-right: auto;
}

/*
  Reload button
  ---------
*/

.EarthOrbitSimulation-reload {
  position: absolute;
  display: block;
  bottom: 10px;
  right: 15px;
  width: 40px;
  height: 40px;
  outline: none;
}

.EarthOrbitSimulation-reload:focus { outline: none; }

.EarthOrbitSimulation-reloadIcon {
  width: 100%;
  border : 0;
}

.EarthOrbitSimulation-massSlider,
.EarthOrbitSimulation-eccentricitySlider {
  max-width: 400px;
  margin: 0 auto;
}

/*

Sick Slider
--------------

*/

.SickSlider {
  position: relative;
  height: 60px;
  cursor: pointer;
}

.SickSlider-stripe {
  height: 5px;
  width: 100%;
  background-color: #999999;
  /*border: 1px solid #a66000;*/
  position: absolute;
  top: 28px;
  left: 0px;
}

.SickSlider-head {
  position: absolute;
  top: 10px;
  left: 0;
  width: 30px;
  height: 40px;
  background-color: #ff9400;
  border: 1px solid #FFFFFF;
}

</style>

<!-- Message shown in old browsers. -->
  <p id="EarthOrbitSimulation-notSupportedMessage" class="EarthOrbitSimulation-alert EarthOrbitSimulation-isHiddenBlock">Please use a newer browser to see the simulation.</p>

<div class="EarthOrbitSimulation-container isFullScreenWide isUnselectable">
    <img src='https://evgenii.com/image/blog/2018-08-17-two-body-problem-simulator/sun.png' alt='Earth' class='EarthOrbitSimulation-sun'>
    <img src='https://evgenii.com/image/blog/2018-08-17-two-body-problem-simulator/earth.png' alt='Earth' class='EarthOrbitSimulation-earth'>
    <img src='https://evgenii.com/image/blog/2018-08-17-two-body-problem-simulator/center_of_mass.png' alt='Earth' class='EarthOrbitSimulation-centerOfMass'>

    <canvas class="EarthOrbitSimulation-canvas"></canvas>

    <div class='EarthOrbitSimulation-hudContainer'>
      <div class='EarthOrbitSimulation-hudContainerChild'>
        <a class='EarthOrbitSimulation-reload' href='#'><img src='https://evgenii.com/image/blog/2016-09-17-ridiculous-strawberry-picking/reload_icon.png' alt='Restart' class='EarthOrbitSimulation-reloadIcon'></a>
      </div>
    </div>
</div>

<div class='EarthOrbitSimulation-isTextCentered EarthOrbitSimulation-hasTopMarginNormal EarthOrbitSimulation-hasNegativeBottomMarginNormal isUnselectable'>
  Mass ratio: <span class='EarthOrbitSimulation-sunsMass'>0.10</span>
</div>

<div class="SickSlider EarthOrbitSimulation-massSlider isUnselectable" >
  <div class="SickSlider-stripe"></div>
  <div class="SickSlider-head"></div>
</div>

<div class='EarthOrbitSimulation-isTextCentered EarthOrbitSimulation-hasTopMarginNormal EarthOrbitSimulation-hasNegativeBottomMarginNormal isUnselectable'>
  Eccentricity: <span class='EarthOrbitSimulation-eccentricity'>0.10</span>
</div>

<div class="SickSlider EarthOrbitSimulation-eccentricitySlider isUnselectable" >
  <div class="SickSlider-stripe"></div>
  <div class="SickSlider-head"></div>
</div>

<p class='EarthOrbitSimulation-debugOutput'></p>

<script>

(function(){
  // A Slider UI element
  function SickSlider(sliderElementSelector) {
    var that = {
      // A function that will be called when user changes the slider position.
      // The function will be passed the slider position: a number between 0 and 1.
      onSliderChange: null,
      // Store the previous slider value in order to prevent calling onSliderChange function with the same argument
      previousSliderValue: -42,
      didRequestUpdateOnNextFrame: false
    };

    // Initializes the slider element
    //
    // Arguments:
    //   sliderElementSelector: A CSS selector of the SickSlider element.
    that.init = function(sliderElementSelector) {
      that.slider = document.querySelector(sliderElementSelector);
      that.sliderHead = that.slider.querySelector(".SickSlider-head");
      var sliding = false;

      // Start dragging slider
      // -----------------

      that.slider.addEventListener("mousedown", function(e) {
        sliding = true;
        that.updateHeadPositionOnTouch(e);
      });

      that.slider.addEventListener("touchstart", function(e) {
        sliding = true;
        that.updateHeadPositionOnTouch(e);
      });

      that.slider.onselectstart = function () { return false; };

      // End dragging slider
      // -----------------

      document.addEventListener("mouseup", function(){
        sliding = false;
      });

      document.addEventListener("dragend", function(){
        sliding = false;
      });

      document.addEventListener("touchend", function(e) {
        sliding = false;
      });

      // Drag slider
      // -----------------

      document.addEventListener("mousemove", function(e) {
        if (!sliding) { return; }
        that.updateHeadPositionOnTouch(e);
      });

      document.addEventListener("touchmove", function(e) {
        if (!sliding) { return; }
        that.updateHeadPositionOnTouch(e);
      });

      that.slider.addEventListener("touchmove", function(e) {
        if (typeof e.preventDefault !== 'undefined' && e.preventDefault !== null) {
          e.preventDefault(); // Prevent screen from sliding on touch devices when the element is dragged.
        }
      });
    };

    // Returns the slider value (a number form 0 to 1) from the cursor position
    //
    // Arguments:
    //
    //   e: a touch event.
    //
    that.sliderValueFromCursor = function(e) {
      var pointerX = e.pageX;

      if (e.touches && e.touches.length > 0) {
        pointerX = e.touches[0].pageX;
      }

      pointerX = pointerX - that.slider.offsetLeft;
      var headLeft = (pointerX - 16);
      if (headLeft < 0) { headLeft = 0; }

      if ((headLeft + that.sliderHead.offsetWidth) > that.slider.offsetWidth) {
        headLeft = that.slider.offsetWidth - that.sliderHead.offsetWidth;
      }

      // Calculate slider value from head position
      var sliderWidthWithoutHead = that.slider.offsetWidth - that.sliderHead.offsetWidth;
      var sliderValue = 1;

      if (sliderWidthWithoutHead !== 0) {
        sliderValue = headLeft / sliderWidthWithoutHead;
      }

      return sliderValue;
    };


    // Changes the position of the slider
    //
    // Arguments:
    //
    //   sliderValue: a value between 0 and 1.
    //
    that.changePosition = function(sliderValue) {
      var headLeft = (that.slider.offsetWidth - that.sliderHead.offsetWidth) * sliderValue;
      that.sliderHead.style.left = headLeft + "px";
    };

    // Update the slider position and call the callback function
    //
    // Arguments:
    //
    //   e: a touch event.
    //
    that.updateHeadPositionOnTouch = function(e) {
      var sliderValue = that.sliderValueFromCursor(e);

      // Handle the head change only if it changed significantly (more than 0.1%)
      if (Math.round(that.previousSliderValue * 1000) === Math.round(sliderValue * 1000)) { return; }
      that.previousSliderValue = sliderValue;

      if (!that.didRequestUpdateOnNextFrame) {
        // Update the slider on next redraw, to improve performance
        that.didRequestUpdateOnNextFrame = true;
        window.requestAnimationFrame(that.updateOnFrame);
      }
    };

    that.updateOnFrame = function() {
      that.changePosition(that.previousSliderValue);

      if (that.onSliderChange) {
        that.onSliderChange(that.previousSliderValue);
      }

      that.didRequestUpdateOnNextFrame = false;
    };

    that.init(sliderElementSelector);

    return that;
  }

  // Show debug messages on screen
  var debug = (function(){
    var debugOutput = document.querySelector(".EarthOrbitSimulation-debugOutput");

    function print(text) {
      var date = new Date();
      debugOutput.innerHTML = text + " " + date.getMilliseconds();
    }

    return {
        print: print,
      };
  })();

  // Runge-Kutta numerical integration
  var rungeKutta = (function() {
    // h: timestep
    // u: variables
    // derivative: function that calculates the derivatives
    function calculate(h, u, derivative) {
      var a = [h/2, h/2, h, 0];
      var b = [h/6, h/3, h/3, h/6];
      var u0 = [];
      var ut = [];
      var dimension = u.length;

      for (var i = 0; i < dimension; i++) {
        u0.push(u[i]);
        ut.push(0);
      }

      for (var j = 0; j < 4; j++) {
        var du = derivative();

        for (i = 0; i < dimension; i++) {
          u[i] = u0[i] + a[j]*du[i];
          ut[i] = ut[i] + b[j]*du[i];
        }
      }

      for (i = 0; i < dimension; i++) {
        u[i] = u0[i] + ut[i];
      }
    }

    return {
      calculate: calculate
    };
  })();

  // Calculates the position of the Earth
  var physics = (function() {
    // Current state of the system
    var state = {
      // Four variables used in the differential equations
      // First two elements are x and y positions, and second two are x and y components of velocity
      u: [0, 0, 0, 0],
      masses: {
        q: 0, // Current mass ratio m2 / m1
        m1: 1,
        m2: 0, // Will be set to q
        m12: 0 // Will be set to m1 + m2
      },
      eccentricity: 0, // Current eccentricity of the orbit
      // Current positions of the two bodies
      positions: [
        {
          x: 0,
          y: 0
        },
        {
          x: 0,
          y: 0
        }
      ],
      iteration: 0 // Temporary REMOVE THIS!!!
    };

    // Initial condition of the model
    var initialConditions = {
      eccentricity: 0.7, // Eccentricity of the orbit
      q: 0.5, // Mass ratio m2 / m1
      position: {
        x: 1,
        y: 0
      },
      velocity: {
        u: 0
      }
    };


    // Calculate the initial velocity of the seconf body
    // in vertical direction based on mass ratio q and eccentricity
    function initialVelocity(q, eccentricity) {
      return Math.sqrt( (1 + q) * (1 + eccentricity) );
    }

    // Update parameters that depend on mass ratio and eccentricity
    function updateParametersDependentOnUserInput() {
      state.masses.m2 = state.masses.q;
      state.masses.m12 = state.masses.m1 + state.masses.m2;
      state.u[3] = initialVelocity(state.masses.q, state.eccentricity);
    }

    function resetStateToInitialConditions() {
      state.masses.q = initialConditions.q;
      state.eccentricity = initialConditions.eccentricity;

      state.u[0] = initialConditions.position.x;
      state.u[1] = initialConditions.position.y;
      state.u[2] = initialConditions.velocity.u;

      updateParametersDependentOnUserInput();
    }

    // Calculate the derivatives of the system of ODEs that describe equation of motion of two bodies
    function derivative() {
      var du = new Array(state.u.length);

      // x and y coordinates
      var r = state.u.slice(0,2);

      // Distance between bodies
      var rr = Math.sqrt( Math.pow(r[0],2) + Math.pow(r[1],2) );

      for (var i = 0; i < 2; i++) {
        du[i] = state.u[i + 2];
        du[i + 2] = -(1 + state.masses.q) * r[i] / (Math.pow(rr,3));
      }

      return du;
    }

    // The main function that is called on every animation frame.
    // It calculates and updates the current positions of the bodies
    function updatePosition() {
      var timestep = 0.15;
      rungeKutta.calculate(timestep, state.u, derivative);
      calculateNewPosition();
    }

    function calculateNewPosition() {
      r = 1; // Distance between two bodies
      // m12 is the sum of two massses
      var a1 = (state.masses.m2 / state.masses.m12) * r;
      var a2 = (state.masses.m1 / state.masses.m12) * r;

      state.positions[0].x = -a2 * state.u[0];
      state.positions[0].y = -a2 * state.u[1];

      state.positions[1].x = a1 * state.u[0];
      state.positions[1].y = a1 * state.u[1];
    }

    // Returns the separatation between two objects
    // This is a value from 1 and larger
    function separationBetweenObjects() {
      return initialConditions.position.x / (1 - state.eccentricity);
    }

    function updateMassRatioFromUserInput(massRatio) {
      state.masses.q = massRatio;
      updateParametersDependentOnUserInput();
    }

    function updateEccentricityFromUserInput(eccentricity) {
      state.eccentricity = eccentricity;
      updateParametersDependentOnUserInput();
    }

    return {
      resetStateToInitialConditions: resetStateToInitialConditions,
      updatePosition: updatePosition,
      initialConditions: initialConditions,
      updateMassRatioFromUserInput: updateMassRatioFromUserInput,
      updateEccentricityFromUserInput: updateEccentricityFromUserInput,
      state: state,
      separationBetweenObjects: separationBetweenObjects
    };
  })();

  // Draw the scene
  var graphics = (function() {
    var canvas = null, // Canvas DOM element.
      context = null, // Canvas context for drawing.
      canvasHeight = 400,
      defaultBodySize = 80,
      colors = {
        orbitalPath: "#5555FF"
      },
      // Previously drawn positions of the two bodies. Used to draw orbital line.
      previousBodyPositions = [
        {x: null, y: null},
        {x: null, y: null}
      ],
      earthElement,
      sunElement,
      currentBodySizes = [
        defaultBodySize, defaultBodySize
      ],
      middleX = 1,
      middleY = 1;

    function drawBody(position, size, bodyElement) {
      var left = (position.x - size/2) + "px";
      var top = (position.y - size/2) + "px";
      bodyElement.style.left = left;
      bodyElement.style.top = top;
    }

    // Updates the sizes of the two object based on the mass ratio (value from 0 to 1)
    // and the scale factor (value from 1 and larger).
    function updateObjectSizes(massRatio, scaleFactor) {
      currentBodySizes[1] = defaultBodySize / scaleFactor;
      sunElement.style.width = currentBodySizes[1] + "px";

      // Assuming same density of two bodies, mass ratio is proportional to the cube of radii ratio
      massRatio = Math.pow(massRatio, 1/3);
      currentBodySizes[0] = defaultBodySize * massRatio / scaleFactor;
      earthElement.style.width = currentBodySizes[0] + "px";
    }

    function drawOrbitalLine(newPosition, previousPosition) {
      if (previousPosition.x === null) {
        previousPosition.x = newPosition.x;
        previousPosition.y = newPosition.y;
        return;
      }

      context.beginPath();
      context.strokeStyle = colors.orbitalPath;
      context.moveTo(previousPosition.x, previousPosition.y);
      context.lineTo(newPosition.x, newPosition.y);
      context.stroke();

      previousPosition.x = newPosition.x;
      previousPosition.y = newPosition.y;
    }

    function calculatePosition(position) {
      middleX = Math.floor(canvas.width / 2);
      middleY = Math.floor(canvas.height / 2);
      var scale = 100;
      var centerX = position.x * scale + middleX;
      var centerY = position.y * scale + middleY;

      return {
        x: centerX,
        y: centerY
      };
    }

    // Draws the scene
    function drawScene(positions) {
      var body1Position = calculatePosition(positions[0]);
      drawBody(body1Position, currentBodySizes[0], earthElement);
      drawOrbitalLine(body1Position, previousBodyPositions[0]);

      var body2Position = calculatePosition(positions[1]);
      drawBody(body2Position, currentBodySizes[1], sunElement);
      drawOrbitalLine(body2Position, previousBodyPositions[1]);
    }

    function showCanvasNotSupportedMessage() {
      document.getElementById("EarthOrbitSimulation-notSupportedMessage").style.display ='block';
    }

    // Resize canvas to will the width of container
    function fitToContainer(){
      canvas.style.width='100%';
      canvas.style.height= canvasHeight + 'px';
      canvas.width  = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;
    }

    // Returns true on error and false on success
    function initCanvas() {
      // Find the canvas HTML element
      canvas = document.querySelector(".EarthOrbitSimulation-canvas");

      // Check if the browser supports canvas drawing
      if (!(window.requestAnimationFrame && canvas && canvas.getContext)) { return true; }

      // Get canvas context for drawing
      context = canvas.getContext("2d");
      if (!context) { return true; } // Error, browser does not support canvas
      return false;
    }

    // Create canvas for drawing and call success argument
    function init(success) {
      if (initCanvas()) {
        // The browser can not use canvas. Show a warning message.
        showCanvasNotSupportedMessage();
        return;
      }

      // Update the size of the canvas
      fitToContainer();

      earthElement = document.querySelector(".EarthOrbitSimulation-earth");
      sunElement = document.querySelector(".EarthOrbitSimulation-sun");

      // Execute success callback function
      success();
    }

    function clearScene() {
      context.clearRect(0, 0, canvas.width, canvas.height);
      previousBodyPositions = [
        {x: null, y: null},
        {x: null, y: null}
      ];
    }

    return {
      fitToContainer: fitToContainer,
      drawScene: drawScene,
      updateObjectSizes: updateObjectSizes,
      clearScene: clearScene,
      init: init
    };
  })();

  // Start the simulation
  var simulation = (function() {
    // The method is called 60 times per second
    function animate() {
      physics.updatePosition();
      graphics.drawScene(physics.state.positions);
      window.requestAnimationFrame(animate);
    }

    function start() {
      graphics.init(function() {
        // Use the initial conditions for the simulation
        physics.resetStateToInitialConditions();
        graphics.updateObjectSizes(physics.initialConditions.q, physics.separationBetweenObjects());

        // Redraw the scene if page is resized
        window.addEventListener('resize', function(event){
          graphics.fitToContainer();
          graphics.clearScene();
          graphics.drawScene(physics.state.positions);
        });

        animate();
      });
    }

    return {
      start: start
    };
  })();

  // React to user input
  var userInput = (function(){
    var sunsMassElement = document.querySelector(".EarthOrbitSimulation-sunsMass");
    var eccentricityElement = document.querySelector(".EarthOrbitSimulation-eccentricity");
    var restartButton = document.querySelector(".EarthOrbitSimulation-reload");
    var massSlider, eccentricitySlider;

    function didUpdateMassSlider(sliderValue) {
      if (sliderValue === 0) { sliderValue = 0.005; }
      var oldEccentricity = physics.state.eccentricity;
      physics.resetStateToInitialConditions();
      graphics.clearScene();
      physics.updateMassRatioFromUserInput(sliderValue);
      physics.updateEccentricityFromUserInput(oldEccentricity);
      graphics.updateObjectSizes(physics.state.masses.q, physics.separationBetweenObjects());
      showMassRatio(sliderValue);
    }

    function showMassRatio(ratio) {
      var formattedRatio = parseFloat(Math.round(ratio * 100) / 100).toFixed(2);
      sunsMassElement.innerHTML = formattedRatio;
    }

    function didUpdateEccentricitySlider(sliderValue) {
      var oldMassRatio = physics.state.masses.q;
      physics.resetStateToInitialConditions();
      graphics.clearScene();
      physics.updateMassRatioFromUserInput(oldMassRatio);
      physics.updateEccentricityFromUserInput(sliderValue);
      showEccentricity(sliderValue);
      graphics.updateObjectSizes(physics.state.masses.q, physics.separationBetweenObjects());
    }

    function showEccentricity(ratio) {
      var formattedRatio = parseFloat(Math.round(ratio * 100) / 100).toFixed(2);
      eccentricityElement.innerHTML = formattedRatio;
    }

    function didClickRestart() {
      physics.resetStateToInitialConditions();
      graphics.clearScene();
      showMassRatio(physics.initialConditions.q);
      showEccentricity(physics.initialConditions.eccentricity);
      massSlider.changePosition(physics.initialConditions.q);
      eccentricitySlider.changePosition(physics.initialConditions.eccentricity);
      graphics.updateObjectSizes(physics.initialConditions.q, physics.separationBetweenObjects());
      return false; // Prevent default
    }

    function init() {
      // Mass slider
      massSlider = SickSlider(".EarthOrbitSimulation-massSlider");
      massSlider.onSliderChange = didUpdateMassSlider;
      showMassRatio(physics.initialConditions.q);
      massSlider.changePosition(physics.initialConditions.q);

      // Eccentricity slider
      eccentricitySlider = SickSlider(".EarthOrbitSimulation-eccentricitySlider");
      eccentricitySlider.onSliderChange = didUpdateEccentricitySlider;
      showEccentricity(physics.initialConditions.eccentricity);
      eccentricitySlider.changePosition(physics.initialConditions.eccentricity);

      restartButton.onclick = didClickRestart;
    }

    return {
      init: init
    };
  })();

  userInput.init();

  simulation.start();
})();

</script>



Wooooow!