A particle proximity visualization demo note

I’ve recently encountered several demos on various parts of the web featuring a rather interesting particle effect. The effect is what appears to be a random population of particles with unique velocities. When a particle encounters another within some proximity, a connecting line is drawn between them, attaching the two particle centers. The overall effect appears similar to a dynamic display of constellations constantly re-arranging their patterning, or strands of a spider web connecting and pulling itself apart.

A family member had asked me to describe the technology being used in one such demo backdropping a website banner. His first guess was an animated GIF or PNG of some sort. Upon further inspection, my intuition proved correct. The banner’s effect leveraged an absolutely positioned HTML canvas. One that responds to window resizing to display responsively. Using such an effect as a backdrop to the banner-in-question elevated the content to appear quite tech-savvy, leaving quite a positive impression on me.

So I embarked on re-creating that effect using HTML canvas and Javascript and decided it would be fun to share my results and process.

Document setup

First thing to do is set up a canvas element in our HTML. We set a default width and height attribute, which we will change dynamically upon our script initialization. Setting the dimension attributes to zero ensures the canvas will not display if the script initialization fails.

We can wrap the canvas in a containing element which can be styled responsively. This will allow us to easily adjust the canvas dimensions to its parent element’s bounds during a window resize by calculating its wrapper’s bounds.

index.html
1
2
3
4
5
<body>
<div class="container">
<canvas width="0" height="0"></canvas>
</div>
</body>

canvas.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
canvas,
.container {
width: 100%;
height: 100%;
}
canvas{
position: absolute;
top: 0;
left: 0;
}
.container {
height: 400px;
position: relative;
overflow: hidden;
}

This will give our parent container elasticity to fill the entire container width while setting a fixed height. Note that you can just as easily allow internal content to determine the height of the parent element by setting the container height property to auto.

We want to position our canvas absolutely and lock it to the top-left corner of its parent container. Any additional banner content should be added inside the container but after the canvas element.

Adjusting canvas dimensions

Our canvas dimensions still need to be set after the page loads and bounding properties of our containing element has a chance to calculate. Adding some script to handle our initialization and window resize events to allow for responsive stretching is straight-forward enough:

main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var canvas;
// adjust the canvas size to its parent element's bounds
function adjustCanvasBounds(){
var parentBounds = canvas.parentNode.getBoundingClientRect();
canvas.width = parentBounds.width;
canvas.height = parentBounds.height;
}
// page initialization behavior
function pageInit(){
canvas = document.querySelector(".container").querySelector("canvas");
// init the canvas dimensions to parent container
adjustCanvasBounds();
}
// add listener to window to initialize page once all resources have loaded
window.addEventListener(
"load",
pageInit);
// add listener to window to handle canvas resizing when the window is resized
window.addEventListener(
"resize",
adjustCanvasBounds);

pageInit() is fired when the page resources have loaded. canvas is given a value during initialization, and adjustCanvasBounds() is manually invoked to initially set the canvas bounds to match its parent’s. A window resize event will trigger the same function to update canvas size to ensure its ‘responsivity’.

We have a few other things about our canvas to consider. Whenever we create or resize a canvas we intend to draw on, window.devicePixelRatio needs to be accounted for. This indicates a pixel ratio for certain displays (such as retina/quad-HD displays) which determines how many actual canvas pixels we have available to utilize. Without this adjustment, objects rendered on our canvas may appear blurry on devices with a pixel ratio > 1. We can accomplish this with an additional function call that happens any time we need to re-size the canvas.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// adjusts the canvas width/height properties to accommodate device pixel ratio
// preserves the style width and height to fit original bounds inside document
function adjustCanvasFidelity() {
var pixelRatio = window.devicePixelRatio || 1;
canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px";
canvas.width *= pixelRatio;
canvas.height *= pixelRatio;
}
// adjusts both canvas bounds and ratio during any future canvas resizing
function adjustCanvasSize(){
adjustCanvasBounds();
adjustCanvasFidelity();
}

Setting the stage

Rather than exposing some of these variables and functions to the top-level scope, I prefer structuring a stage object which contains a reference to the canvas, a rendering context, and some methods that allow for common interaction with the canvas. An initial state might look something like the following:

stage.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function Stage(canvasEl){
// private variables
var canvas = canvasEl instanceof Node ?
canvasEl :
document.querySelector(canvasEl);
context = canvas.getContext("2d"),
pixelRatio = window.devicePixelRatio || 1;
// adjust the canvas size to its parent element's bounds
function _adjustCanvasBounds(){
var parentBounds = canvas.parentNode.getBoundingClientRect();
canvas.width = parentBounds.width;
canvas.height = parentBounds.height;
}
// adjusts the canvas width/height properties to accommodate device pixel ratio
// preserves the style width and height to fit original bounds inside document
function _adjustCanvasFidelity() {
canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px";
canvas.width *= pixelRatio;
canvas.height *= pixelRatio;
}
// public-accessible object
return {
// property getters
get height(){
return stage.height;
},
get width(){
return stage.width;
},
get pixelRatio(){
return pixelRatio;
},
// resize the canvas
resize: function(){
_adjustCanvasBounds();
_adjustCanvasFidelity();
},
// rendering properties to go here
}
}
// create object instance
var stage = new Stage("canvas");

Much of the code in the Stage object is a re-hash of what we already wrote in its more functional form. A canvas reference is established from the constructors main parameter, and a canvas context is defined, exposing an avenue to the canvas’s rendering API. _adjustCanvasBounds() and _adjustCanvasFidelity() now live in the scope of the constructor and is exposed with a resize() public method. The rest are getters that return private variable values in read-only form.

Setting up a render loop

With particles ultimately moving around on-stage, the Stage object will need some way to manage animation. To achieve this, a loop must be created to tell the stage’s canvas to redraw each particle position at some given time coefficient or time increment. Ideally our rendering loop should support pausing & resuming. Our looping logic doesn’t have to explicitly define specific rendering instructions within the loop itself. Passing a reference to a function defining those steps is adequate enough.

Stage.render()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
render: (function() {
var paused = false, // a flag indicating state of animation loop
requestID, // animation request ID reported during loop
renderMethod; // function reference detailing all draw operations per frame
// public methods
return {
// once invoked, creates a rendering loop which in turn invokes drawMethod parameter,
// passing along the delta (in milliseconds) since the last frame draw
begin: function(drawMethod) {
// cache the draw method to invoke per frame
renderMethod = drawMethod || renderMethod || function(){};
// a reference to requestAnimationFrame object
// this should also utilize some vendor prefixing and a setTimeout fallback
var requestFrame = window.requestAnimationFrame,
latestTime,
startTime = Date.now(),
// during each interval, clear the canvas and invoke renderMethod
intervalMethod = function(tick) {
this.clear();
renderMethod(context, tick);
}.bind(this);
// start animation loop
(function loop() {
// calculate tick time between frames
var now = Date.now(),
tick = now - latestTime || 1;
// update latest time stamp
latestTime = now;
// report tick value to intervalCallback
intervalMethod(tick);
// loop iteration if no pause state is set
requestID = paused ? null : requestFrame(loop);
})();
},
// other methods controlling state go here
}
}())

Focusing mainly on the animation controller inside of render.begin(), parameter drawMethod() ultimately defines a series of specific rendering operations to perform during each available animation frame. We are using the window.requestAnimationFrame object to space each loop sequence to delay additional frame rendering to a time when the browser is ready. This greatly reduces hiccups and inconsistencies in framerates, allowing the rendering logic and screen drawing to remain synchronous.

Before the loop begins, a starting timestamp is cached. Function intervalMethod() is defined, which essentially calls a yet-to-be defined method clear(), which is responsible for wiping the canvas’s contents each frame before a new frame is rendered. Without clearing the canvas, successive calls will paint ontop of the existing pixel data.

The loop function is immediately invoked, creating its own scope, where the most current timestamp is cached, and the ‘tick’ is calculated from the difference between the most recent timestamp and the second most recent timestamp. The intervalMethod is invoked, ultimately triggering the argument-supplied renderMethod(), providing both a context and a tick time as arguments. The handler method will need those references to properly calculate new positions for each particle, and a context to which they will be rendered. Notice this keyword is bound to the function as other member methods are referenced within the function scope, ensuring that this keyword remains bound to the same reference upon successive rendering loops.

Finally, our paused flag is evaluated to determine if the loop should recurse or not. If so, the requestAnimationFrame alias requestFrame() is invoked, with the loop function as an argument. When invoked, requestFrame returns a reference ID. Storing this ID will allow us to later cancel the request if an interruption occurs before the next render cycle takes place.

Expanding on the methods of property render are state-controlling functions for pausing and resumption of the rendering loop, and a convenient method to handle a complete pixel data wipe of the canvas.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// clears the stage's canvas
clear: function() {
context.clearRect(0, 0, canvas.width, canvas.height);
},
// pause the canvas rendering
pause: function() {
paused = true;
cancelAnimationFrame(requestID);
},
// resumes the animation with a given rendering method
resume: function() {
paused = false;
this.begin(renderMethod);
}

When pause() is invoked, the cached requestID reported from the alias requestFrame() method is used as an argument to cancelAnimationFrame(), preventing any pending requests from executing. Method resume() clears the flag and invokes the loop method to begin again.

With these handler methods, we have a complete stage ready to handle rendering of our particle logic. So the next logical step is to define precisely what a particle is.

Defining a particle object

Like our Stage object, a particle would best be represented as an object with some inherent properties and methods. We use the new keyword to create a new instance of the object along with some unique properties for that instance.

Lets define some basic information that a particle animating on our canvas might require:

  • positional data ( [x,y] offsets )
  • dimensions or radius
  • a center point if different from positional data
  • velocity ( both direction and speed )
  • an influence threshold

With this list in mind, we can reserve some private variables within our object constructor’s scope as placeholders for these currently unknown values. We can assume these properties will be determined upon object instantiation, and passed as an argument as a dictionary or object literal format.

particle.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function Particle(props) {
// private particle properties
var v = props.speed, // scalar value for particle velocity
t = props.theta, // angular theta value for particle velocity
r = props.radius, // particle radius
x = props.x || 0, // initial x-axial position on canvas
y = props.y || 0, // initial y-axial position on canvas
influence = props.influence * stage.pixelRatio,
color = props.color;
// public properties and methods
return {
// position property getter: read-only
get x() {
return x;
},
get y() {
return y;
},
// compares instance x,y location to another point centered at a,b
// returns a ratio describing closeness from a scale of 0-1
influencedBy: function(a, b){
var hyp = Math.sqrt(Math.pow(a - x, 2) + Math.pow(b - y, 2));
return Math.abs(hyp) <= influence ?
hyp / influence : 0;
},
// render this particle to a given context
render: function(ctx) {
ctx.strokeStyle = color;
ctx.lineWidth = 1 / r * r * stage.pixelRatio;
ctx.beginPath();
ctx.arc(x, y, r, 0, 8);
ctx.stroke();
},
// sets the position of this particle compared to its previous position
// in relation to a total time delta since last positioning
// positions infinitely loop within canvas bounds
setPosition: function(timeDelta) {
// calculate x position along path of origin at timeDelta
x += timeDelta / v * Math.cos(t);
// wrap particle on either x boundary
x = x > stage.width + r ? 0 - r : x;
x = x < 0 - r ? stage.width + r : x;
// calculate y position along path of origin at timeDelta
y += timeDelta / v / 2 * Math.sin(t);
// wrap particle on either y boundary
y = y > stage.height + r ? 0 - r : y;
y = y < 0 - r ? stage.height + r : y;
}
}
// create a particle instance
var particle = new Particle({/*property values defined here*/});

The Particle constructor props parameter can certainly be referenced via object notation to access its property values at any time. To cut down on any chance of that access to impact performance on a larger scale, I’ve chosen to cache those values in private variables within the constructor. This code also assumes all required properties are present in the parameter object.

The x and y position values are exposed as read-only properties, as well as several methods with different function.

Method influencedBy() compares the distance of some point at [a,b] to its own [x,y] position and returns a ratio of ‘closeness’, or strength of influence from that point. The closer the point in question, the higher the ratio value, ranging from a scale of 0-1. This ratio will allow ease of calculations for values of variance, such as connecting line-width and opacity.

Method render() contains a series of operations that are responsible for rendering the particle to an argument-supplied context. For more details on canvas rendering API, I recommend MDN’s thorough documentation on the subject.

Lastly, method setPosition() updates the particle instance’s position over a certain time delta provided as an argument. The formula uses both speed and theta properties to determine how far the particle has traveled after n milliseconds. Edge cases provide a canvas looping mechanism for the particle, so disappearing from one edge of the canvas will allow it to reappear on the opposing boundary. The method only updates the x and y property values, leaving rendering responsibilities out of scope of its purpose.

Putting it all together

Now that we have both Stage and Particle components, some controlling logic is required to pull the pieces together. A list form of actions not-yet built include:

  • particle generation with randomized properties
  • populating and managing an array of particle instances
  • a rendering routine for all particles as part of the Stage’s renderMethod
  • drawing connective lines between two particles within each other’s influence

I intend to create a ParticleGroup object that would manage these higher-level tasks in some well-defined structure, something resembling the following:

particle_group.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function ParticleGroup() {
var _collection = [],
_connectColor = options.connector.color,
_connectWidth = options.connector.lineWidth,
_particleCount = options.particle.count;
// generates and returns a new particle instance with
// randomized properties within ranges defined in the options object
function _generateNewParticle() {
return new Particle({
/*particle properties defined here*/
});
}
// queries other particles to see if a connection between particles should be rendered
function _checkForNeighboringParticles() {}
// renders a connecting line between the two particles
function _connectParticles() {}
// public object
return {
// adds a particle instance to collection
add: function(p) {
collection.push(p || _generateNewParticle());
},
// initial population of bar collection
populate: function() {
for (var i = 0; i < _particleCount; i++) {
this.add();
}
},
// loops through all particle instances within collection and
// invokes instance rendering method on a given context at tick coefficient t
render: function(ctx, t) {}
};
}

Notice an options object is being referenced, which is a dictionary containing certain property values collected in one convenient location. These values are being cached as private variables for quick reference inside the constructor’s scope.

The constructor has a private array _collection to store the particle instances once method populate() is invoked. Method add() pushes either a supplied instance or a newly generated particle to the collection via private function _generateNewParticle(). Public method render() contains the logic to loop through instances within the collection to first position them after some time offset, render the instance onto the stage, and check for neighboring particles via private function _checkForNeighboringParticles(). If one or more particles are identified, _connectParticles() is responsible for rendering a connecting line between two points.

Taking a closer look at the rendering loop, I’ve written a solution that looks like this:

ParticleGroup.render()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function(ctx, t){
// loop through each particle, position it and render
for (var i = 0, p; i < _particleCount; i++) {
p = _collection[i];
p.checked = false;
p.setPosition(t);
p.render(ctx);
}
// loop through each particle, check for connectors to be rendered with neighboring particles
for (var i = 0, p; i < _particleCount; i++) {
p = _collection[i];
_checkForNeighboringParticles(ctx, p);
p.checked = true;
}
}

My First reaction when writing this was, “That’s potentially a lot of looping”. To ensure that all these loops are as efficient as possible, loop variables are aggressively cached.

The initial loop sets a flag property to false to indicate that it has not been thoroughly ‘checked’ for neighboring particle connections. This has to be reset during each loop. Additionally, each particle’s position is repositioned at time offset t. Finally the particle is then rendered on-stage.

The second loop takes place after positioning and rendering of each particle has taken place. Each particle is then checked to see what other particles fall within its sphere of influence via private function _checkForNeighboringParticles(). A context is forwarded, provided as an argument from the stage.render() method. the particle is then flagged as ‘checked’ so future cycles through the neighbor-checking loop can skip redundant comparisons with them.

Taking a closer look at the neighbor-checking function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function _checkForNeighboringParticles(ctx, p1) {
// cache particle influence method
var getInfluenceCoeff = p1.influencedBy;
// particle collection iterator
for (var i = 0, p2, d; i < _particleCount; i++) {
p2 = _collection[i];
// skip if particles are the same or if p2 has been previously checked
if (p1 !== p2 && !p2.checked) {
// compare the distance delta between the two particles
d = getInfluenceCoeff(p2.x, p2.y);
// render the connector if coefficient is non-zero
d ?_connectParticles(ctx, p1.x, p1.y, p2.x, p2.y, d) : null;
}
}
}

Again, loop variables are cached whenever possible, as well as p1’s influenceBy() method. When looping through unknown number of iterations, property access and in particular prototype traversing can be costly.

The collection is iterated (again) and non-checked particles are compared against p1 to see if the hypoteneuse between their centers is less than p1’s sphere of influence. This ratio is reported back to variable d, and if non-zero, private function connectParticles() is invoked to render a line between the two particle’s center points on the provided context.

Result

Our init function has to be slightly reconfigured to instantiate each of our objects. The ParticleGroup instance should populate its collection of particle instances with the populate() method. Afterwards, the Stage instance render loop needs to start via Stage.render.begin(). The reconfigured init() function looks as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
var particles, stage;
function init(){
// create object instances
particles = new ParticleGroup();
stage = new Stage(document.querySelector("canvas"));
// init the canvas bounds and fidelity
stage.resize();
// populate particle group collection
particles.populate();
// begin stage rendering with the renderAll draw routine on each tick
stage.render.begin(
particles.render.bind(particles));
}

The resulting demo creates quite a pleasant effect, with easily editable options which control properties of interest such as particle count, spheres of influence radius, velocity and size ranges, and color definitions.

See the Pen Spheres of Influence by steve s (@st0ven) on CodePen.