This pen went viral some time ago (august 3), and some people asked me "how does it work?". I then realized that the maths behind it are probably pretty obscure if you didn't write the implementation yourself. So here is the answer to your questions!

HTML and CSS

Let's just start by the setup, which is the easiest part. The html is literally just a <canvas> element with an id of c. A few already got confused by that, so let me just say that since pretty much the start of HTML5 every browser allows for non-quotes attributes as long as there are no spaces in them. It's just much faster to type, I save a whole 4 keystrokes! I know it's not a good reason at all, but that's how I roll so get over it ;)

  <canvas id=c></canvas>

The css is also extremely simple. I set position: absolute to the canvas so that the coordinate reference is now set on the window, no longer on the body, then I just set it's left and top properties to 0, to remove the 8px white "border" (not really a border). There are other ways to do it, but this also allows for adding other elements without having to add additional css to make sure the canvas doesn't change position

  canvas {

    position: absolute;
    top: 0;
    left: 0;
}

The interesting part: JS

Now we come to what makes this pen what it is. The first thing I should do is clear up the logics. To make you understand the logics, I reccomend taking a look at the opts object

  // something
        opts = {

            rays: 30,
            maxRadius: Math.sqrt( w*w/4 + h*h/4 ),
            circleRadiusIncrementAcceleration: 2,
            radiantSpan: .4,
            rayAngularVelSpan: .005,
            rayAngularVelLineWidthMultiplier: 60,
            rayAngularAccWaveInputBaseIncrementer: .03,
            rayAngularAccWaveInputAddedIncrementer: .02,
            rayAngularAccWaveMultiplier: .0003,
            baseWaveInputIncrementer: .01,
            addedWaveInputIncrementer: .01,
            circleNumWaveIncrementerMultiplier: .1,

            cx: w / 2,
            cy: h / 2,
            tickHueMultiplier: 1,
            shadowBlur: 0,
            repaintAlpha: .2,
            apply: init
        },
// something

In there you can see every (or almost) constant in my code. It's not necessary to keep them in an object like this, but I think it's helpful in case you want to change values without having to scroll to much and to help others understand why on earth that number is there and what it represents. Usually from this and from looking at the pen you can get to the logic without much trouble, but I'm going to walk you through anyway, of course.

What is happening?

What you see in the pen are basically some rays with a rotation based on the center which has a sin accelerated angular velocity. The concept is not hard: the ray has a property rot (rotation), which is incremented by angularVel (angular velocity), which is being incremented by a sin wave. We first need to describe the sin wave: we need to specify the input (angularAccWaveInput) to insert (which should change over time) and a scaling number with which the result of the wave should be multiplied. This means we also need to specify by how much should the input be incremented (angularAccWaveInputIncrementer), and I got this value to be different for every ray so that the difference in behaviour for each is noticeably varying. The scaling number is simply a constant (opts.rayAngularAccWaveMultiplier). Let's take a look at what we have in the Ray constructor so far:

  function Ray(){

    //...

    this.rot = Math.random() * Math.PI * 2;
    this.angularVel = Math.random() * opts.rayAngularVelSpan * ( Math.random() < .5 ? 1 : -1 );
    this.angularAccWaveInput = Math.random() * Math.PI * 2;
    this.angularAccWaveInputIncrementer = opts.rayAngularAccWaveInputBaseIncrementer + opts.rayAngularAccWaveInputAddedIncrementer * Math.random();

    //...
}
Ray.prototype.step = function(){

    this.rot += 
        this.angularVel += Math.sin( 
            this.angularAccWaveInput += 
                this.angularAccWaveInputIncrementer ) * opts.rayAngularAccWaveMultiplier;

    //...
}

At a first glance it might seem excedingly complicated, but just look closer:

  • this.rot = Math.random() * Math.PI * 2 just means that the rotation along the center can be literally anything since we're working with radians, granting some relatively distributed rays
  • this.angularVel = Math.random() * const * ( Math.random() < .5 ? 1 : -1 ) just means that the initial angular velocity should be spread across the const span and has a 50% chance of being negative
  • the input of a sin wave only varies in the first turn, so this.angularAccWaveInput could correspond to any value of the sin function
  • this.angularAccWaveInputIncrementer = baseConst + addedConst * Math.random() just means that the spread of the function is addedConst, its min is baseConst and its max is baseConst + addedConst
  • rot is incremented by angularVel which is incremented by the scaled sin wave of angularAccWaveInput which gets incremented by angularWaveInputIncrementer

This allows for some pretty interesting results: the sum of the values of the sine wave every cycle is 0, even if it is scaled, which means that the average velocity is constant. The graph of rot then looks a bit like the blue line:

This isn't really important if you don't have too much confidence in your calculus, but if you do, keep in mind that the derivative of that is 1 + cos(x) (in red), which reaches 0 when cos = -1, so when x = Math.PI . For those of you who just prefer to leave calculus in the books, just remember that sometimes it looks like there are horizontal-like lines in that graph, which means that basically the rotation won't change in some cases therefore the velocity will be 0 (the velocity is just the derivative of the position ;) ). That will be very important for the flashy effect later, also remember that because of the scaling number and the different initial velocities, this varies a lot, but this will be the average result. Believe it or not we're almost there on the logics!

Next up is making the armonically random waves on the ray with wave-like movement. You may have noticed that the waves grow bigger and bigger as they get away from the center, both in size and in frequency (actually, smaller and smaller in frequency, but you know what I mean). The waves are actually simply well-placed quadratic bezier curves through some points moving with sine motion along a circle. Let's take a moment to really understand what you just read and let's take a look at the Circle constructor in all it's glory:

  function Circle( n ){

    this.radius = opts.circleRadiusIncrementAcceleration * Math.pow( n, 2 );
    this.waveInputIncrementer = ( opts.baseWaveInputIncrementer + opts.addedWaveInputIncrementer * Math.random() ) * ( Math.random() < .5 ? 1 : -1 ) * opts.circleNumWaveIncrementerMultiplier * n;
    this.waveInput = Math.random() * Math.PI * 2;
    this.radiant = Math.random() * opts.radiantSpan * ( Math.random() < .5 ? 1 : -1 );
}
Circle.prototype.step = function(){

    this.waveInput += this.waveInputIncrementer;
    this.radiant = Math.sin( this.waveInput ) * opts.radiantSpan;
}

Just as before, don't just give up on it: read it line by line until you said "that was easy, not sure why I was scared". The first thing to be noted is that the argument passed through the constructor is simply the index of the circle, we'll get to it later. So now I can give you two explanations to why this.radius is what it is, here's the one for who knows his basic physics: I wanted the effect to simulate the footprint of a circle whose radius is accelerated, and you probably know the formula a*(t^2)/2 gives you the position at a certain time relative to the acceleration at that time. We mark a and 1/2 as constants, so we can just put them in one constant k, obtaining p = k(t^2) saving us a calculation and making the code look a bit nicer. In our case t is always an integer, and so you get this nice quadratic footprint. The explanation for who doesn't bother getting into physics it's just that it looks nice and it works. Here's the circles (includes a quick jsfiddle link):

The movement is not that different from the one of the ray itself, but instead of applying the wave first to the acceleration, then to the velocity and only in the end to the position, we apply it to the position instead. This is mainly to save calculations, but also not to have the rays too spread out ad make them follow an almost straight line on average. So the setup for the variables is almost identical to the one of the ray. "But if the radiant only spans a specified radiant, how come they follow the general direction of the ray?" you may ask, and you'd be asking a good question. To see the answer the Ray constructor needs to be finished. Here is the whole thing:

  function Ray(){

    this.circles = [ new Circle( 0 ) ];
    this.rot = Math.random() * Math.PI * 2;
    this.angularVel = Math.random() * opts.rayAngularVelSpan * ( Math.random() < .5 ? 1 : -1 );
    this.angularAccWaveInput = Math.random() * Math.PI * 2;
    this.angularAccWaveInputIncrementer = opts.rayAngularAccWaveInputBaseIncrementer + opts.rayAngularAccWaveInputAddedIncrementer * Math.random();

    var security = 100,
            count = 0;

    while( --security > 0 && this.circles[ count ].radius < opts.maxRadius )
        this.circles.push( new Circle( ++count ) );
}
Ray.prototype.step = function(){

    // this is just messy, but if you take your time to read it properly you'll understand it pretty easily
    this.rot += 
        this.angularVel += Math.sin( 
            this.angularAccWaveInput += 
                this.angularAccWaveInputIncrementer ) * opts.rayAngularAccWaveMultiplier;

    var rot = this.rot,
            x = opts.cx,
            y = opts.cy;

    ctx.lineWidth = Math.min( .00001 / Math.abs( this.angularVel ), 10 / opts.rayAngularVelLineWidthMultiplier ) * opts.rayAngularVelLineWidthMultiplier;

    ctx.beginPath();
    ctx.moveTo( x, y );

    for( var i = 0; i < this.circles.length; ++i ){

        var circle = this.circles[ i ];

        circle.step();

        rot += circle.radiant;

        var x2 = opts.cx + Math.sin( rot ) * circle.radius,
                y2 = opts.cy + Math.cos( rot ) * circle.radius,

                mx = ( x + x2 ) / 2,
                my = ( y + y2 ) / 2;

        ctx.quadraticCurveTo( x, y, mx, my );

        x = x2;
        y = y2;
    }

    ctx.strokeStyle = ctx.shadowColor = 'hsl(hue,80%,50%)'.replace( 'hue', ( ( ( rot + this.rot ) / 2 ) % ( Math.PI * 2 ) ) / Math.PI * 30 + tickHueMultiplied );

    ctx.stroke();
}

This is exactly the same code you'll find in the pen. The first thing to notice is that Ray now has a circles array property. The only reason for which I already have a circle in there is to simplify the while checking condition, which allows me to only use a single statement in it to add circles. That security variable is just to make sure the while doesn't explode for some unexpected reason. It's not necessary, but it was very useful while testing and I decided to leave it there in case I wanted to fiddle around with it a bit more. Also remember ++variable is pre-incrementation: it first increments the variable, then does everything else, unlike variable++.

The remaining logic lies in the for loop in Ray.prototype.step. First of all I set an initial x, y, and rot that will be used later to make some relative measurements, then I iterate through the circles and the first thing I do of course is call their .step function, then to the rotation in the previous iteration I add the rotation of the circle I'm iterating for now, which just means that the rotations are all relative to the previous ones, and that's how I get the wavy-in-an-straight-line-on-average-effect.

We're almost done! The only thing to do now is the rendering: we don't want to just have straight lines, otherwise it would look like this:

without bezier curves

If you ask me it still looks pretty cool, but it's just not enough for me. The technique for making smooth quadratic curves follow a path without making spikes and such is not really hard, but you do need to know a decent amount of maths before understanding what to do. The trick is basically to use the points of the path as control points, and use the midpoints with the other points to make the end and start points. I made a tool for you, since it's pretty funny to play around with this technique, hopefully it will make you understand how it works a little bit better:

But how did I do the flashy effect? It's all in ctx.lineWidth. Have a look at the graph of this function:

graph of |1/x|, 3

Assign x to your velocity, and notice that the smaller it is, the bigger the output, and at one point it almost rockets up! Now I just need to prevent it from growing too big, and how do I do that? I just min() it with another number to limit it. We said that the smaller the velocity is, the bigger the width, right? Remember when I said that you can see that on average the velocity ( or derivative of the position ) hits 0? 0 is as small as you can get in an |x| function, that means that the stiller the ray, the bigger the line width. But since it's a wave, you only get a flash! To be fair, I almost didn't expect this to work the first time I wrote the pen, but the effect just gives an awesome little addition :D

Maybe the only part that may still be confusing is the loop:

  function loop(){

    window.requestAnimationFrame( loop );

  ++tick;

    ctx.globalCompositeOperation = 'source-over';
    ctx.shadowBlur = 0;
    ctx.fillStyle = 'rgba(0,0,0,alp)'.replace( 'alp', opts.repaintAlpha );
    ctx.fillRect( 0, 0, w, h );
    ctx.shadowBlur = opts.shadowBlur;
    ctx.globalCompositeOperation = 'lighter';

    tickHueMultiplied = opts.tickHueMultiplier * tick;

    rays.map( function( ray ){ ray.step(); } );
}

What I do is basically add a transparency to the repaint to make the user able to track the movement of the lines a bit better, and the globalCompositeOperation = 'lighter' simply makes overlapping lines look very bright, that's pretty much all you need to know.

Conclusion

I hope you enjoyed both the pen and the guide! If you have any questions/see any typos feel free to contact me in the comments or on twitter (@MateiCopot)!

I have to say this: I do know that some major optimizations could be applied, but I barely care. If you want to do it then go ahead, good for you!


9,683 0 34