1
\$\begingroup\$

I have ported my simulation from Java to vanilla Javascript. The most important aim was to keep the sum of all energies constant. I have this:

simulation.js

var Configuration = {
    PIXELS_PER_UNIT_LENGTH: 10.0,
    FORCE_CONSTANT: 500.0,
    SLEEP_TIME: 10,
    TIME_STEP: 0.01,
    MINIMUM_PARTICLE_MASS: 10.0,
    MAXIMUM_PARTICLE_MASS: 30.0,
    MAXIMUM_INITIAL_VELOCITY: 40.0,
    DEFAULT_NUMBER_OF_PARTICLES: 10
};

function Particle(mass, radius) {
    this.mass = mass;
    this.radius = radius;
    this.x = 0.0;
    this.y = 0.0;
    this.velocityX = 0.0;
    this.velocityY = 0.0;
}

Particle.prototype.getMass = function() {
    return this.mass;
};

Particle.prototype.getX = function() {
    return this.x;
};

Particle.prototype.getY = function() {
    return this.y;
};

Particle.prototype.getVelocityX = function() {
    return this.velocityX;
};

Particle.prototype.getVelocityY = function() {
    return this.velocityY;
};

Particle.prototype.setX = function(x) {
    this.x = x;
};

Particle.prototype.setY = function(y) {
    this.y = y;
};

Particle.prototype.setVelocityX = function(velocityX) {
    this.velocityX = velocityX;
};

Particle.prototype.setVelocityY = function(velocityY) {
    this.velocityY = velocityY;
};

Particle.prototype.getSpeed = function() {
    var vxSquared = this.velocityX * this.velocityX;
    var vySquared = this.velocityY * this.velocityY;
    return Math.sqrt(vxSquared + vySquared);
};

Particle.prototype.getDistance = function(other) {
    var dx = this.x - other.x;
    var dy = this.y - other.y;
    return Math.sqrt(dx * dx + dy * dy);
};

Particle.prototype.getKineticEnergy = function() {
    var speed = this.getSpeed();
    return 0.5 * this.mass * speed * speed;
};

Particle.prototype.getRadius = function() {
    return this.radius;
};

function getForce(particle1, particle2) {
    var distance = particle1.getDistance(particle2);
    var mass1 = particle1.getMass();
    var mass2 = particle2.getMass();
    return Configuration.FORCE_CONSTANT * mass1 * mass2 / (distance * distance);
}

function getForceVector(particle1, particle2) {
    var vectorLength = getForce(particle1, particle2);
    var dx = particle1.getX() - particle2.getX();
    var dy = particle1.getY() - particle2.getY();
    var angle = Math.atan2(dy, dx);
    var xComponent = vectorLength * Math.cos(angle);
    var yComponent = vectorLength * Math.sin(angle);
    return new Vector(xComponent, yComponent);
}

function getPotentialEnergy(particle1, particle2) {
    var mass1 = particle1.getMass();
    var mass2 = particle2.getMass();
    var distance = particle1.getDistance(particle2);
    return Configuration.FORCE_CONSTANT * mass1 * mass2 / distance;
}

function ParticleRenderer(particle, color, canvasContext) {
    this.particle = particle;
    this.color = color;
    this.canvasContext = canvasContext;
}

ParticleRenderer.prototype.draw = function() {
    var effectiveX = this.particle.getX() * Configuration.PIXELS_PER_UNIT_LENGTH;
    var effectiveY = this.particle.getY() * Configuration.PIXELS_PER_UNIT_LENGTH;

    this.canvasContext.fillStyle = this.color;
    this.canvasContext.beginPath();
    this.canvasContext.arc(effectiveX, 
                           effectiveY, 
                           this.particle.getRadius() * 
                                   Configuration.PIXELS_PER_UNIT_LENGTH, 
                           0, 
                           2 * Math.PI, 
                           false);
    this.canvasContext.fill();
};

function Vector(x, y) {
    this.x = x;
    this.y = y;
}

Vector.prototype.getX = function() {
    return this.x;
};

Vector.prototype.getY = function() {
    return this.y;
};

Vector.prototype.plus = function(other) {
    return new Vector(this.x + other.x, this.y + other.y);
};

Vector.prototype.multiply = function(factor) {
    return new Vector(this.x * factor, this.y * factor);
};

function SimulationEngine(canvasContext, 
                          canvasElement,
                          particles,
                          renderers,
                          timeStep,
                          worldWidth,
                          worldHeight,
                          sleepTime) {
    this.canvasContext            = canvasContext;
    this.canvasElement            = canvasElement;
    this.particles                = particles;
    this.renderers                = renderers;
    this.timeStep                 = timeStep;
    this.totalEnergy              = null;
    this.worldWidth               = worldWidth;
    this.worldHeight              = worldHeight;
    this.sleepTime                = sleepTime;
    this.exit                     = false;
    this.pause                    = true;
    this.particleToForceVectorMap = {};
}

SimulationEngine.prototype.togglePause = function() {
    this.pause = !this.pause;
};

SimulationEngine.prototype.run = function() {
    this.redraw();
    this.totalEnergy = this.computeTotalEnergy();
    var self = this;

    setInterval(function() {
        if (!self.pause) {
            self.performStep();
            self.redraw();
        }
    }, this.sleepTime);
};

SimulationEngine.prototype.performStep = function() {
    this.computeForceVectors();
    this.updateParticleVelocities();
    this.moveParticles();
    this.resolveWorldBorderCollisions();
    this.normalizeVelocityVectors();
    this.particleToForceVectorMap = {};
};

SimulationEngine.prototype.redraw = function() {
    this.canvasContext.fillStyle = "#000";
    this.canvasContext.fillRect(0, 
                                0, 
                                this.canvasElement.width, 
                                this.canvasElement.height);

    for (var i = 0; i < this.renderers.length; ++i) {
        var renderer = this.renderers[i];
        renderer.draw();
    }

    this.canvasContext.fillStyle = "#fff";
    this.font = "20px Arial";
    this.canvasContext.fillText("Total energy: " + this.computeTotalEnergy(), 
                                0, 
                                30);
};

SimulationEngine.prototype.computeForceVectors = function() {
    this.particleToForceVectorMap = [];

    for (var i = 0; i < this.particles.length; ++i) {
        var particle = this.particles[i];
        var vector = new Vector(0.0, 0.0);

        for (var j = 0; j < this.particles.length; ++j) {
            var otherParticle = this.particles[j];

            if (i === j) {
                continue;
            }

            var aux = getForceVector(particle, otherParticle);
            vector = vector.plus(aux);
        }

        this.particleToForceVectorMap.push([particle, vector]);
    }
};

SimulationEngine.prototype.updateParticleVelocities = function() {
    for (var i = 0; i < this.particleToForceVectorMap.length; ++i) {
        var particle = this.particleToForceVectorMap[i][0];
        var vector   = this.particleToForceVectorMap[i][1];

        vector = vector.multiply(1.0 / particle.getMass());
        particle.setVelocityX(particle.getVelocityX() + vector.getX() * this.timeStep);
        particle.setVelocityY(particle.getVelocityY() + vector.getY() * this.timeStep);
    }
};

SimulationEngine.prototype.moveParticles = function() {
    for (var i = 0; i < this.particles.length; ++i) {
        var particle = this.particles[i];
        particle.setX(particle.getX() + particle.getVelocityX() * this.timeStep);
        particle.setY(particle.getY() + particle.getVelocityY() * this.timeStep);
    }
};

SimulationEngine.prototype.resolveWorldBorderCollisions = function() {
    for (var i = 0; i < this.particles.length; ++i) {
        var particle = this.particles[i];
        var radius = particle.getRadius();

        if (particle.getY() - radius <= 0.0) {
            particle.setVelocityY(-particle.getVelocityY());
        } else if (particle.getY() + radius >= this.worldHeight) {
            particle.setVelocityY(-particle.getVelocityY());
        }

        if (particle.getX() - radius <= 0.0) {
            particle.setVelocityX(-particle.getVelocityX());
        } else if (particle.getX() + radius >= this.worldWidth) {
            particle.setVelocityX(-particle.getVelocityX());
        }
    }
};

SimulationEngine.prototype.normalizeVelocityVectors = function() {
    var totalEnergyDelta = this.computeTotalEnergyDelta();
    var factor = this.getNormalizationFactor(totalEnergyDelta);

    for (var i = 0; i < this.particles.length; ++i) {
        var particle = this.particles[i];
        particle.setVelocityX(factor * particle.getVelocityX());
        particle.setVelocityY(factor * particle.getVelocityY());
    }
};

SimulationEngine.prototype.getNormalizationFactor = function(totalEnergyDelta) {
    var aux = totalEnergyDelta / this.computeTotalKineticEnergy() + 1.0;

    if (aux < 0.0) {
        console.log("aux: " + aux);
    }

    return Math.sqrt(aux);
};

SimulationEngine.prototype.computeTotalKineticEnergy = function() {
    var energy = 0.0;
    for (var i = 0; i < this.particles.length; ++i) {
        var particle = this.particles[i];
        energy += particle.getKineticEnergy();
    }

    return energy;
};

SimulationEngine.prototype.computeTotalEnergyDelta = function() {
    return this.totalEnergy - this.computeTotalEnergy();
};

SimulationEngine.prototype.computeTotalEnergy = function() {
    var totalEnergy = 0.0;

    for (var i = 0; i < this.particles.length; ++i) {
        totalEnergy += this.particles[i].getKineticEnergy();
    }

    for (var i = 0; i < this.particles.length; ++i) {
        var particle1 = this.particles[i];

        for (var j = i + 1; j < this.particles.length; ++j) {
            var particle2 = this.particles[j];
            totalEnergy += getPotentialEnergy(particle1, particle2);
        }
    }

    return totalEnergy;
};

function setCanvasDimensions(canvasElement) {
    canvasElement.width = window.innerWidth;
    canvasElement.height = window.innerHeight;
}

function createRandomParticles(number_of_particles, 
                               worldWidth, 
                               worldHeight,
                               canvasContext) {
    var particleData = [];

    for (var i = 0; i < number_of_particles; ++i) {
        particleData.push(createRandomParticleData(worldWidth, 
                                                   worldHeight, 
                                                   canvasContext));
    }

    return particleData;
}

function getRandomColor() {
    var letters = "0123456789abcdef";
    var color = "#";

    for (var i = 0; i < 6; ++i) {
        color += letters[Math.floor(Math.random() * 16)];
    }

    return color;
}

function createRandomParticleData(worldWidth, worldHeight, canvasContext) {
    var mass = Configuration.MINIMUM_PARTICLE_MASS + 
              (Configuration.MAXIMUM_PARTICLE_MASS - Configuration.MINIMUM_PARTICLE_MASS) 
             * Math.random();
    var radius = mass / Configuration.PIXELS_PER_UNIT_LENGTH;
    var particle = new Particle(mass, radius);
    particle.setX(worldWidth * Math.random());
    particle.setY(worldHeight * Math.random());
    particle.setVelocityX(Configuration.MAXIMUM_INITIAL_VELOCITY * Math.random());
    particle.setVelocityY(Configuration.MAXIMUM_INITIAL_VELOCITY * Math.random());
    var color = getRandomColor();
    var renderer = new ParticleRenderer(particle, color, canvasContext);
    return [particle, renderer];
}

function extractParticles(particleData) {
    particles = [];

    for (var i = 0; i < particleData.length; ++i) {
        particles.push(particleData[i][0]);
    }

    return particles;
}

function extractRenderers(particleData) {
    particles = [];

    for (var i = 0; i < particleData.length; ++i) {
        particles.push(particleData[i][1]);
    }

    return particles;
}

function main() {
    var canvasElement = document.getElementById("cnvs");
    setCanvasDimensions(canvasElement);
    var canvasContext = canvasElement.getContext("2d");

    var worldWidth  = canvasElement.width  / Configuration.PIXELS_PER_UNIT_LENGTH;
    var worldHeight = canvasElement.height / Configuration.PIXELS_PER_UNIT_LENGTH;

    var particleData = 
            createRandomParticles(Configuration.DEFAULT_NUMBER_OF_PARTICLES,
                                  worldWidth,
                                  worldHeight,
                                  canvasContext);

    var particles = extractParticles(particleData);
    var renderers = extractRenderers(particleData);

    var simulationEngine = new SimulationEngine(canvasContext,
                                                canvasElement,
                                                particles,
                                                renderers,
                                                Configuration.TIME_STEP,
                                                worldWidth,
                                                worldHeight,
                                                Configuration.SLEEP_TIME);
    window.onkeydown = function(e) {
        if (e.keyCode === 32) {
            simulationEngine.togglePause();
        }
    };                               

    simulationEngine.run();
}

main();

index.html

<!DOCTYPE html>
<html>
    <head>
        <title>simulating...</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <canvas id="cnvs" style="background-color: black; margin:0; padding:0;">Your browser does not support HTML5 canvases!</canvas>
        <script src="simulation.js"></script>
    </body>
</html>

You can view the simulation here. (Press Space bar for turning pause on/off.)

Critique request

Since I am not a professional Javascript developer, it would be nice to hear the experts' opinions. Also, how should I go about making sure that the canvas occupies the entire view port?

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

This already looks fairly clean, I was able to read through most of the code without needing to pause and think very often. Good work! Here's a few thoughts.

  1. In both Vector and Particle, there are currently methods to get and set all properties. While in Java this may be a best practice (I've never really touched Java). It isn't necessary or a good idea in the JavaScript world. I really like Eric Reppen's answer on SO regarding getters and setters. Simply doing away with these methods removes ~40 lines which is nearly always a good thing.

  2. Particles have both a velocity and a location. Both the velocity and direction happen to be vectors. I would advise using the Vector class to represent both of these quantities. While doing this, I would recommend rewriting Particle.prototype.getDistance to use the vector methods subtract and getLength. Particle.prototype.getSpeed can also simply return this.velocity.getLength().

  3. I am not convinced that ParticleRenderer needs to be a class as it exposes only a single method. I would recommend refactoring it into a simple function renderParticle.

  4. SimulationEngine requires a lot of parameters. It would be great if some could be reduced. In this case, the constructor can get the canvasContext if passed the canvasElement and thus that parameter can simply be removed.

  5. Instead of redrawing the simulation every sleepTime, you may want to use window.requestAnimationFrame. This may require tweaking the force constant to keep the particle movement consistent.

  6. SimulationEngine.prototype.performStep sets this.particleToForceVectorMap to an empty object. This is redundant as computeForceVectors sets it to an empty array when running and confusing since the type of the property changes. I recommend removing this assignment.

  7. 0.0 is exactly the same as 0. JavaScript has just one number type. There is no need as in Java to use a decimal to avoid integer division.

  8. I would highly recommend using the ES6 let and const to avoid using var to avoid some confusing behavior at times.

  9. Setting window.onkeydown is a bad idea as the listener could be overwritten accidentally by another script doing the same. Use window.addEventListener('keydown', function() {}) instead. Also, avoid magic numbers. e.keyCode === 32 is confusing. e.key === ' ' is less confusing.


Other notes:

  1. The current method of reversing the velocity when a particle runs into a wall doesn't work correctly, particles will eventually work their way off the screen. An easy fix for this would be to just check if a particle will be rendered off the screen and move it to the nearest position before rendering.

  2. Making the canvas occupying the entire viewport is a question for SO, this question might help.

  3. ES6 added class syntax which can make declaring prototype methods less verbose. Many people smarter than I believe it is a bad idea to use it. However, you may be interested in reading up on it and deciding if the syntactical sugar is a good idea for your use case. Personally, I quite like the OLOO pattern described here, though intellisense support is not very good.

  4. Gravitational force is attractive, thus it is misleading to use mass and then have particles repel each other. I would recommend using charge.

  5. Keeping the same logic for the most part, here is an alternative implementation of the system simulation with most of my suggestions implemented. I ended up removing the concept of a world size, instead using the size of the canvas, and have adjusted the constants so that essentially the same effect is realized. There is certainly still room for improvement. Particles can still get "stuck" on a wall will stay on that wall since the wall does not produce a repulsive force.

const Configuration = {
    PIXELS_PER_UNIT_LENGTH: 10,
    FORCE_CONSTANT: 7000,
    SLEEP_TIME: 33,
    TIME_STEP: 0.1,
    MINIMUM_PARTICLE_charge: 10,
    MAXIMUM_PARTICLE_charge: 30,
    MAXIMUM_INITIAL_VELOCITY: 40,
    DEFAULT_NUMBER_OF_PARTICLES: 10
};

const Vector = {
    init(x, y) {
        this.x = x;
        this.y = y;
        return this;
    },

    add(other) {
        return Object.create(Vector).init(this.x + other.x, this.y + other.y)
    },

    subtract(other) {
        return Object.create(Vector).init(other.x - this.x, other.y - this.y);
    },

    multiply(factor) {
        return Object.create(Vector).init(this.x * factor, this.y * factor);
    },

    get magnitude() {
        return Math.sqrt(this.x ** 2 + this.y ** 2);
    }
}

const Particle = {
    init(charge, radius, color) {
        this.charge = charge;
        this.radius = radius;
        this.color = color;
        this.position = Object.create(Vector).init(0, 0);
        this.velocity = Object.create(Vector).init(0, 0);
        return this;
    },

    getDistance(other) {
        return this.position.subtract(other.position).magnitude;
    },

    getForceVector(other) {
        // A particle exerts no force on itself.
        if (this == other) return Object.create(Vector).init(0, 0);


        let length = Configuration.FORCE_CONSTANT * this.charge * other.charge / this.getDistance(other) ** 2;
        let angle = Math.atan2(this.position.y - other.position.y, this.position.x - other.position.x);
        let xComponent = length * Math.cos(angle);
        let yComponent = length * Math.sin(angle);
        return Object.create(Vector).init(xComponent, yComponent);
    },

    getKineticEnergy() {
        return 0.5 * this.charge * this.velocity.magnitude ** 2;
    },

    getPotentialEnergy(other) {
        return Configuration.FORCE_CONSTANT * this.charge * other.charge / this.getDistance(other);
    },

    render(context) {
        context.fillStyle = this.color;
        context.beginPath();
        context.arc(
            this.position.x, this.position.y, // Position
            this.radius * Configuration.PIXELS_PER_UNIT_LENGTH, // Radius
            0, Math.PI * 2 // Degrees
        );
        context.fill();
    }
}

const SimulationEngine = {
    init(canvas, particles, timeStep, sleepTime) {
        this.canvas = canvas;
        this.context = canvas.getContext('2d');
        this.particles = particles;
        this.timeStep = timeStep;
        this.sleepTime = sleepTime;
        this.intervalId = null;
        return this;
    },

    start() {
        this.totalEnergy = this.computeTotalEnergy();
        this.intervalId = setInterval(() => {
            this.step();
            this.render();
        }, this.sleepTime)
    },

    stop() {
        clearInterval(this.intervalId);
    },

    step() {
        this.updateParticleVelocities(this.computeForceVectors());
        this.moveParticles();
        this.resolveBorderCollisions();
        this.normalizeVelocityVectors();
    },

    computeForceVectors() {
        return this.particles.map(particle => {
            let vector = this.particles.reduce(
                (vector, other) => {
                    return vector.add(particle.getForceVector(other));
                },
                Object.create(Vector).init(0, 0)
            );
            return [particle, vector];
        });
    },

    updateParticleVelocities(particleVectorMap) {
        particleVectorMap.forEach(([particle, vector]) => {
            vector = vector.multiply(1 / particle.charge);
            particle.velocity.x += vector.x * this.timeStep;
            particle.velocity.y += vector.y * this.timeStep;
        });
    },

    moveParticles() {
        this.particles.forEach(particle => {
            particle.position.x += particle.velocity.x * this.timeStep;
            particle.position.y += particle.velocity.y * this.timeStep;
        });
    },

    resolveBorderCollisions() {
        this.particles.forEach(particle => {
            let radius = particle.radius * Configuration.PIXELS_PER_UNIT_LENGTH;

            if (particle.position.y - radius < 0) {
                particle.position.y = radius;
                particle.velocity.y = -particle.velocity.y;
            } else if (particle.position.y + radius > this.canvas.height) {
                particle.position.y = this.canvas.height - radius;
                particle.velocity.y = -particle.velocity.y;
            }

            if (particle.position.x - radius < 0) {
                particle.position.x = radius;
                particle.velocity.x = -particle.velocity.x;
            } else if (particle.position.x + radius > this.canvas.width) {
                particle.position.x = this.canvas.width - radius;
                particle.velocity.x = -particle.velocity.x;
            }
        });
    },

    normalizeVelocityVectors() {
        let totalKineticEnergy = this.computeKineticEnergy();
        let totalEnergy = this.computeTotalEnergy();

        let factor = Math.sqrt((this.totalEnergy - totalEnergy) / totalKineticEnergy + 1);

        this.particles.forEach(particle => {
            particle.velocity.x *= factor;
            particle.velocity.y *= factor;
        });
    },

    computeTotalEnergy() {
        let total = 0;
        this.particles.forEach((particle, index) => {
            total += particle.getKineticEnergy();
            for (let i = index + 1; i < this.particles.length; i++) {
                total += particle.getPotentialEnergy(this.particles[i])
            }
        });
        return total;
    },

    computeKineticEnergy() {
        return this.particles.reduce((energy, particle) => energy + particle.getKineticEnergy(), 0);
    },

    render() {
        this.context.fillStyle = '#000';
        this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);

        this.particles.forEach(particle => particle.render(this.context));

        this.context.fillStyle = '#fff';
        this.context.fillText('Total Energy: ' + this.computeTotalEnergy(), 0, 30);
    },
}

function getRandomColor() {
    var letters = "0123456789abcdef";
    var color = "#";

    for (var i = 0; i < 6; ++i) {
        color += letters[Math.floor(Math.random() * 16)];
    }

    return color;
}

function createRandomParticle(canvas) {
    let charge = Configuration.MINIMUM_PARTICLE_charge +
        (Configuration.MAXIMUM_PARTICLE_charge - Configuration.MINIMUM_PARTICLE_charge)
        * Math.random();
    let radius = charge / Configuration.PIXELS_PER_UNIT_LENGTH;
    let particle = Object.create(Particle).init(charge, radius, getRandomColor())
    particle.position.x = canvas.width * Math.random();
    particle.position.y = canvas.height * Math.random();
    particle.velocity.x = Configuration.MAXIMUM_INITIAL_VELOCITY * Math.random();
    particle.velocity.y = Configuration.MAXIMUM_INITIAL_VELOCITY * Math.random();

    return particle;
}

function createRandomParticles(total, canvas) {
    let particles = [];
    for (let i = 0; i < total; i++) {
        particles.push(createRandomParticle(canvas));
    }
    return particles;
}

const pluck = (arr, key) => arr.map(item => item[key]);

function main() {
    let canvas = document.querySelector('canvas');
    canvas.width = innerWidth
    canvas.height = innerHeight

    let particles = createRandomParticles(Configuration.DEFAULT_NUMBER_OF_PARTICLES, canvas);

    let engine = Object.create(SimulationEngine).init(
        canvas,
        particles,
        Configuration.TIME_STEP,
        Configuration.SLEEP_TIME
    );

    engine.start();
    let started = true;

    window.addEventListener('keydown', e => {
        if (e.key == ' ') {
            if (started) {
                engine.stop();
                started = false;
            } else {
                engine.start();
                started = true;
            }
        }
    });
}

main();
<style>
    body, html {
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
        overflow: hidden;
    }
    canvas {
        position:absolute;
    }
</style>
<canvas>Your browser does not support the HTML5 canvas!</canvas>

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.