Mastering the HTML5 Canvas Part 2

Graham Murray / Monday, June 24, 2013

Introduction

This continues my series on mastering the HTML5 canvas. Last time we got started with rendering a simple shape, and then applying some animation to it. This time we’ll be getting much more ambitious. We’ll be rendering many different shapes, using strokes, rendering text, reacting to mouse movement over the canvas, and simulating 3d graphics using transformation matrices.

Our goal will be to make in interesting eye-catcher. Something eye pleasing you can throw in a layout to attract attention and maybe act as a navigation click target. Our goal will be to make something dynamic that would have been hard to achieve before the advent of the HTML5 canvas.

Here’s a preview of what we will achieve.

The Static Elements

Our finished eye catcher will have some static elements

  • The Backing Shape
  • The Numeric Text

And some dynamic orbiting colored bubbles. But for now lets just get the static elements set up. If you remember from last time, the canvas is an immediate mode API, so we will actually be drawing the static elements every animation frame, rather than just inserting them into a visual tree. Another option for the static elements would be to render them into their own canvas, so that they would not need to be re-rendered every time, but in this case, we’d like the bubbles to pass in front and behind of the text so we’d like to control the order in which we interleave the static elements with the dynamic elements for each frame.

First we’ll start with the DOM elements again:

<canvas id="canvas"></canvas>
<div>
    <input type="text" id="number" value="80" />
    <input type="button" id="changeNumber" value="Change Number" />
</div>

This should look pretty familiar from last time. We are creating a canvas element which we can reference by id later, and two input elements that we can use to modify the behavior of the sample at runtime.

Now, here is the logic, piece by piece:

function ensureQueueFrame() {
    if (!window.queueFrame) {
        if (window.requestAnimationFrame) {
            window.queueFrame = window.requestAnimationFrame;
        } else if (window.webkitRequestAnimationFrame) {
            window.queueFrame = window.webkitRequestAnimationFrame;
        } else if (window.mozRequestAnimationFrame) {
            window.queueFrame = window.mozRequestAnimationFrame;
        } else {
            window.queueFrame = function (callback) {
                window.setTimeout(1000.0 / 60.0, callback);
            };
        }
    }
}

Again, this should seem familiar from last time, its a polyfill to try to call the requestAnimationFrame API, if available, or fallback on calling window.setTimeout to drive our animation otherwise.

Next we perform the same steps as last time in order to get a reference to the canvas element and then get a 2D canvas context to interact with:

$(function () {
    var canv, context, lastTime, duration, progress, forward = true,
        fullWidth = 300,
        fullHeight = 300,
        halfWidth = fullWidth / 2.0,
        halfHeight = fullHeight / 2.0,
        centerX = fullWidth / 2.0,
        centerY = fullWidth / 2.0,
        maxWidth,
        closeness,
        halfMaxWidth,
        number;
    ensureQueueFrame();

    $("#canvas").attr("width", fullWidth + "px").attr("height", fullHeight + "px");
    canv = $("#canvas")[0];
    context = canv.getContext("2d");

We are going to let our interaction affect the animation in the canvas this time, so we’ll use jQuery bind some handlers to mouse over the canvas and clicking the button we defined before.

closeness = 1.0;

number = parseInt($("#number").val(), 10);
$("#changeNumber").click(function (e, ui) {
    number = parseInt($("#number").val(), 10);
});

$("#canvas").mousemove(function (e, ui) {
    var x = e.pageX - $("#canvas").offset().left,
        y = e.pageY - $("#canvas").offset().top,
        max = fullWidth / 2.0;

    closeness = 1.0;

    if (x >= 0 && x <= fullWidth && y >= 0 && y <= fullWidth) {
        closeness = Math.sqrt(
        Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
        closeness = closeness / max;
        if (closeness > 1.0) {
            closeness = 1.0;
        }
    }
});

In this code we are trying to capture how close the mouse is to the center of the canvas element, if over it. We will use this later to drive various aspects of the animation of both the static and dynamic elements of the scene.

We are binding a mousemove handler against the canvas element we defined earlier. Then we are determining the relative position of the mouse cursor over the canvas, and then calculating the distance to the center. Then, given the distance to the center of the canvas, we are turning that into a a value between 0 and 1 that we are storing in closeness, for use later.

Here is the main draw method, defined as we did last time. But rather than the simple ellipse we rendered last time we are drawing all the static elements discussed above:

lastTime = new Date().getTime();

duration = 2500;
maxWidth = fullWidth * 0.55;
halfMaxWidth = maxWidth / 2.0;

function draw() {
    var ellapsed, time,
    fill, width, delta,
    unit;

    queueFrame(draw);

    time = new Date().getTime();
    ellapsed = time - lastTime;
    lastTime = time;

    delta = ellapsed / duration;
    context.clearRect(0, 0, fullWidth, fullHeight);

    context.font = "35pt Verdana";

    fill = "hsl(0, 0%, 95%)";
    context.fillStyle = fill;
    context.strokeStyle = 'hsl(0, 0%, 75%)';
    context.lineWidth = 10;
    context.beginPath();
    context.arc(centerX, centerY,
    halfWidth - 20, 0, 2 * Math.PI, false);
    context.fill();
    context.stroke();

    context.strokeStyle = 'hsl(210,50%,' + Math.round(40 + 20 * (1 - closeness)) + '%)';
    context.fillStyle = 'hsl(210,50%,' + Math.round(60 + 20 * (1 - closeness)) + '%)';
    context.textBaseline = 'middle';
    context.lineWidth = 2;
    width = context.measureText(number.toString()).width;
    context.fillText(number.toString(), centerX - (width / 2.0), halfWidth);
    context.strokeText(number.toString(), centerY - (width / 2.0), halfHeight);
}

queueFrame(draw);

Here we are measuring the elapsed time and progress in the same fashion as last time. We aren’t using the elapsed time as of yet, though, since that is for the dynamic elements to follow. Then we come to the actual static elements of the scene. First we have:

fill = "hsl(0, 0%, 95%)";
context.fillStyle = fill;
context.strokeStyle = 'hsl(0, 0%, 75%)';
context.lineWidth = 10;
context.beginPath();
context.arc(centerX, centerY,
halfWidth - 20, 0, 2 * Math.PI, false);
context.fill();
context.stroke();

First, we are setting the fill style for the background circle. Then we set the stroke style and line width. As mentioned last time, most css color strings can be used for the fill and stroke styles for paths. Then we begin the path and draw an arc as we did last time to represent a circle. But unlike last time, we are both filling and then stroking the path. Since we chose such as large line width, this creates quite a thick border for our backing circle in the scene.

We are using hsl colors in the above because its a reasonably easy way for us to select several colors that work well together. For example we can lock the hue and saturation and vary the lightness, as we did above, or lock down the hue and lightness and vary the saturation, as we will do later.

context.font = "35pt Verdana";

context.strokeStyle = 'hsl(210,50%,' + Math.round(40 + 20 * (1 - closeness)) + '%)';
context.fillStyle = 'hsl(210,50%,' + Math.round(60 + 20 * (1 - closeness)) + '%)';
context.textBaseline = 'middle';
context.lineWidth = 2;
width = context.measureText(number.toString()).width;
context.fillText(number.toString(), centerX - (width / 2.0), halfHeight);
context.strokeText(number.toString(), centerY - (width / 2.0), halfHeight);

For the last static element, the text, we are again setting a fill and stroke style, but this time we are letting the closeness of the mouse cursor affect the lightness value of the hsl color we are assigning. This has the effect, when you run the code, such that if you move the mouse closer to the center of the canvas, you will see the text in the center get lighter.

We then ask the canvas context to measure the width of the text we want to render (in this case, it is the number that is entered into the text input field), so that we can offset the text and center it around the middle of the canvas.

We then fill and stroke the text positioned so that it is centered around the center horizontally. The fact that we set the text baseline to “middle” ensures that the text is centered vertically if we chose the y value to be the precise center of the canvas on the y axis.

Filling the text will apply the fill style that we selected to the font that we selected (35pt Verdana, again most css text strings will work). And the strokeText call will actually stroke an outline around the text using the strokeStyle we selected. Pretty neat huh?

You can see the results here:

You can click through to the actual sample in jsfiddle and see what happens when you hover over the canvas, or change the number displayed in the center. Note that all the click of Change Number is doing is to update a local variable with a new number value. It doesn’t need to explicitly cause the canvas to refresh as the change will automatically be picked up when the next frame renders.

The Dynamic Elements

Whew, that was a lot involved there huh? Some of the complexity above, though, is to now help us throw in the dynamic elements also. Our goal is to create a series of circles that appear to be orbiting around the central text in 3 dimensions, but we aren’t actually going to use a 3d rendering api to do this. We will, instead, calculate the position of each circle in 3 dimensions, but then project it into our 2d plane. We will use the z coordinate to simulate depth by scaling both the size and lightness of the circle as it moves further from the viewer.

The series of steps we will perform is:

  • Generate a random series of points about the origin of our scene.
  • For each of these points, pick a perpendicular axis that intersects the origin to rotate the point around. In this way, all the points appear to orbit the origin point.
  • For each animation frame, for each point:
    • Generate a series of 3d transformation matrices that rotate our point around its axis.
    • Use the transformation matrices to rotate the point a certain delta amount around that axis.
    • Project the point into the viewing plane and decide its size and modified color.
    • Render this point to the plane.

Our other goal above is, if you imagine the text being suspended in the center of the 3D volume of the scene. We want to paint circles that are behind it first, and circles that are in front of it last.

As follows is the modified logic to add the dynamic elements into the scene. Note, there isn’t built in support for transformation matrices in the JavaScript base class library. As such I had to put some of this together by hand. If doing something more complex, there do appear to be JavaScript libraries out there for manipulating vectors, matrices and quaternions. Quaternions, in particular, would simplify rotating a point about an arbitrary axis a little bit.

function ensureQueueFrame() {
    if (!window.queueFrame) {
        if (window.requestAnimationFrame) {
            window.queueFrame = window.requestAnimationFrame;
        } else if (window.webkitRequestAnimationFrame) {
            window.queueFrame = window.webkitRequestAnimationFrame;
        } else if (window.mozRequestAnimationFrame) {
            window.queueFrame = window.mozRequestAnimationFrame;
        } else {
            window.queueFrame = function (callback) {
                window.setTimeout(1000.0 / 60.0, callback);
            };
        }
    }
}

function transform(point3, matrices) {
    var vector = [point3[0], point3[1], point3[2], 1],
        newVector,
        matrix;

    for (var m = matrices.length - 1; m >= 0; m--) {
        newVector = [];
        matrix = matrices[m];
        for (var i = 0; i < 4; i++) {
            newVector[i] = 0;
            for (var j = 0; j < 4; j++) {
                newVector[i] += vector[j] * matrix[j][i];
            }
        }
        vector = newVector;
    }

    return [vector[0], vector[1], vector[2]];
}

function getRotationMatrices(axisPoint, rotationAngle) {
    var len = Math.sqrt(
    axisPoint[0] * axisPoint[0] + axisPoint[1] * axisPoint[1] + axisPoint[2] * axisPoint[2]);
    var a = axisPoint[0] / len;
    var b = axisPoint[1] / len;
    var c = axisPoint[2] / len;

    var d = Math.sqrt(b * b + c * c);

    var matrices = [];

    matrices.push([
        [1, 0, 0, 0],
        [0, c / d, -b / d, 0],
        [0, b / d, c / d, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [d, 0, -a, 0],
        [0, 1, 0, 0],
        [a, 0, d, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [Math.cos(rotationAngle), -Math.sin(rotationAngle), 0, 0],
        [Math.sin(rotationAngle), Math.cos(rotationAngle), 0, 0],
        [0, 0, 1, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [d, 0, a, 0],
        [0, 1, 0, 0],
        [-a, 0, d, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [1, 0, 0, 0],
        [0, c / d, b / d, 0],
        [0, -b / d, c / d, 0],
        [0, 0, 0, 1]
    ]);

    return matrices;
}

$(function () {
    var canv, context, lastTime, duration, progress, forward = true,
        closeness = 1.0,
        fullWidth = 300,
        fullHeight = 300,
        halfWidth = fullWidth / 2.0,
        halfHeight = fullHeight / 2.0,
        centerX = fullWidth / 2.0,
        centerY = fullWidth / 2.0;
    ensureQueueFrame();

    $("#canvas").attr("width", fullWidth + "px").attr("height", fullHeight + "px");
    canv = $("#canvas")[0];
    context = canv.getContext("2d");
    closeness = 1.0;

    $("#canvas").mousemove(function (e, ui) {
        var x = e.pageX - $("#canvas").offset().left,
            y = e.pageY - $("#canvas").offset().top,
            max = fullWidth / 2.0;

        closeness = 1.0;

        if (x >= 0 && x <= fullWidth && y >= 0 && y <= fullWidth) {
            closeness = Math.sqrt(
            Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
            closeness = closeness / max;
            if (closeness > 1.0) {
                closeness = 1.0;
            }
        }
    });

    $("#canvas").mouseleave(function (e, ui) {
        closeness = 1.0;
    });

    $("#changeNumber").click(function (e, ui) {
        var number = parseInt($("#number").val(), 10);
        createPoints(number);
    });

    lastTime = new Date().getTime();

    duration = 2500;
    progress = 0;

    var points = [];

    var maxWidth = fullWidth * 0.55;
    var halfMaxWidth = maxWidth / 2.0;

    function createPoints(num) {
        points.length = 0;
        for (var i = 0; i < num; i++) {
            var currentPoint = [Math.random() * maxWidth - halfMaxWidth, Math.random() * maxWidth - halfMaxWidth, Math.random() * maxWidth - halfMaxWidth];

            var len = Math.sqrt(currentPoint[0] * currentPoint[0] + currentPoint[1] * currentPoint[1] + currentPoint[2] * currentPoint[2]);
            var normalVector = [currentPoint[0] / len, currentPoint[1] / len, currentPoint[2] / len];

            var axisPointX = Math.random() * maxWidth - halfMaxWidth;
            var axisPointY = Math.random() * maxWidth - halfMaxWidth;
            var axisPointZ = (-normalVector[0] * axisPointX - normalVector[1] * axisPointY) / normalVector[2];

            var h = 210.0;
            var s = 30 + Math.round(Math.random() * 70.0);
            var l = 50;

            points.push({
                axisPoint: [axisPointX, axisPointY, axisPointZ],
                currentPoint: currentPoint,
                theta: Math.random() * Math.PI * 2.0,
                color: [h, s, l],
                speedModifier: 0.7 + Math.random() * 0.3
            });
        }
    }

    createPoints(80);

    function draw() {
        var ellapsed, time, b, toRender,
        size, opacity, l, h, s, fill, width, delta,
        point, matrices, location, len,
        unit, newLen, i;
        queueFrame(draw);


        time = new Date().getTime();
        ellapsed = time - lastTime;
        lastTime = time;

        delta = ellapsed / duration;
        context.clearRect(0, 0, fullWidth, fullHeight);
        toRender = [];

        for (i = 0; i < points.length; i++) {
            point = points[i];
            point.theta += delta * Math.PI * 2.0 * point.speedModifier * closeness;
            if (point.theta > Math.PI * 2.0) {
                point.theta -= Math.PI * 2.0;
            }
            matrices = getRotationMatrices(point.axisPoint, point.theta);
            location = transform(point.currentPoint, matrices);

            len = Math.sqrt(location[0] * location[0] + location[1] * location[1] + location[2] * location[2]);
            unit = [location[0] / len, location[1] / len, location[2] / len];
            newLen = len + (maxWidth - len) * (1.0 - closeness) * 0.3;
            location = [unit[0] * newLen, unit[1] * newLen, unit[2] * newLen];

            point.location = location;

            toRender.push(point);
        }

        //TODO: do radix sort here, probably
        toRender.sort(function (p1, p2) {
            if (p1.location[2] < p2.location[2]) {
                return -1;
            } else if (p1.location[2] > p2.location[2]) {
                return 1;
            }
            return 0;
        });

        context.font = "35pt Verdana";

        context.globalAlpha = 1.0;
        fill = "hsl(0, 0%, 95%)";
        context.fillStyle = fill;
        context.strokeStyle = 'hsl(0, 0%, 75%)';
        context.lineWidth = 10;
        context.beginPath();
        context.arc(centerX, centerY,
        halfWidth - 20, 0, 2 * Math.PI, false);
        context.fill();
        context.stroke();

        location = toRender[0].location;
        if (location[2] > 0) {
            context.strokeStyle = 'hsl(210,50%,' + Math.round(40 + 20 * (1 - closeness)) + '%)';
            context.fillStyle = 'hsl(210,50%,' + Math.round(60 + 20 * (1 - closeness)) + '%)';
            context.textBaseline = 'middle';
            context.lineWidth = 2;
            width = context.measureText(points.length.toString()).width;

            context.fillText(points.length.toString(), centerX - (width / 2.0), halfWidth);
            context.strokeText(points.length.toString(), centerY - (width / 2.0), halfHeight);
        }


        for (i = 0; i < toRender.length; i++) {
            context.globalAlpha = Math.min(1.0, 0.2 + closeness);
            location = toRender[i].location;
            size = ((location[2] - (-halfWidth)) / fullWidth) * 8.0 + 2.0;
            opacity = ((location[2] - (-halfWidth)) / fullWidth) * 0.8 + 0.2;

            l = Math.round((0.9 + -0.6 * opacity) * 100.0);
            h = toRender[i].color[0];
            s = toRender[i].color[1];

            fill = 'hsl(' + h + ',' + s + '%,' + l + '%)';
            context.fillStyle = fill;
            context.beginPath();
            context.arc(location[0] + centerX,
            fullHeight - (location[1] + centerY),
            size, 0, 2 * Math.PI, false);
            context.fill();

            if (location[2] <= 0 && ((i == toRender.length - 1) || (toRender[i + 1].location[2] > 0))) {
                context.globalAlpha = 1.0;
                context.strokeStyle = 'hsl(210,50%,' + Math.round(40 + 20 * (1 - closeness)) + '%)';
                context.fillStyle = 'hsl(210,50%,' + Math.round(60 + 20 * (1 - closeness)) + '%)';
                context.textBaseline = 'middle';
                context.lineWidth = 2;
                width = context.measureText(points.length.toString()).width;

                context.fillText(points.length.toString(), centerX - (width / 2.0), halfWidth);
                context.strokeText(points.length.toString(), centerY - (width / 2.0), halfHeight);
            }
        }
    }

    queueFrame(draw);
});

In the above, we have these additions:

function createPoints(num) {
    points.length = 0;
    for (var i = 0; i < num; i++) {
        var currentPoint = [Math.random() * maxWidth - halfMaxWidth, Math.random() * maxWidth - halfMaxWidth, Math.random() * maxWidth - halfMaxWidth];

        var len = Math.sqrt(currentPoint[0] * currentPoint[0] + currentPoint[1] * currentPoint[1] + currentPoint[2] * currentPoint[2]);
        var normalVector = [currentPoint[0] / len, currentPoint[1] / len, currentPoint[2] / len];

        var axisPointX = Math.random() * maxWidth - halfMaxWidth;
        var axisPointY = Math.random() * maxWidth - halfMaxWidth;
        var axisPointZ = (-normalVector[0] * axisPointX - normalVector[1] * axisPointY) / normalVector[2];

        var h = 210.0;
        var s = 30 + Math.round(Math.random() * 70.0);
        var l = 50;

        points.push({
            axisPoint: [axisPointX, axisPointY, axisPointZ],
            currentPoint: currentPoint,
            theta: Math.random() * Math.PI * 2.0,
            color: [h, s, l],
            speedModifier: 0.7 + Math.random() * 0.3
        });
    }
}

Here we are generating our random series of points. To get a pleasing set of distinct colors, we are locking down the hue and lightness and just varying the saturation of the colors we are using. Once we have generated the location of each point we are calculating its normal vector from the origin, and then finding a perpendicular vector to that vector that also originates from the origin. Then we store all of that information in our points collection.

Next we have:

function transform(point3, matrices) {
    var vector = [point3[0], point3[1], point3[2], 1],
        newVector,
        matrix;

    for (var m = matrices.length - 1; m >= 0; m--) {
        newVector = [];
        matrix = matrices[m];
        for (var i = 0; i < 4; i++) {
            newVector[i] = 0;
            for (var j = 0; j < 4; j++) {
                newVector[i] += vector[j] * matrix[j][i];
            }
        }
        vector = newVector;
    }

    return [vector[0], vector[1], vector[2]];
}

function getRotationMatrices(axisPoint, rotationAngle) {
    var len = Math.sqrt(
    axisPoint[0] * axisPoint[0] + axisPoint[1] * axisPoint[1] + axisPoint[2] * axisPoint[2]);
    var a = axisPoint[0] / len;
    var b = axisPoint[1] / len;
    var c = axisPoint[2] / len;

    var d = Math.sqrt(b * b + c * c);

    var matrices = [];

    matrices.push([
        [1, 0, 0, 0],
        [0, c / d, -b / d, 0],
        [0, b / d, c / d, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [d, 0, -a, 0],
        [0, 1, 0, 0],
        [a, 0, d, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [Math.cos(rotationAngle), -Math.sin(rotationAngle), 0, 0],
        [Math.sin(rotationAngle), Math.cos(rotationAngle), 0, 0],
        [0, 0, 1, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [d, 0, a, 0],
        [0, 1, 0, 0],
        [-a, 0, d, 0],
        [0, 0, 0, 1]
    ]);

    matrices.push([
        [1, 0, 0, 0],
        [0, c / d, b / d, 0],
        [0, -b / d, c / d, 0],
        [0, 0, 0, 1]
    ]);

    return matrices;
}

Here we are defining a method to transform a 3d point by a series of transformation matrices, and then defining a method that generates a series of transformation matrices to rotate a 3d point around an arbitrary axis. These are the tools we will need to transform our points in 3D space down in our draw function.

Then, we have modified our draw function to look like this:

function draw() {
    var ellapsed, time, b, toRender,
    size, opacity, l, h, s, fill, width, delta,
    point, matrices, location, len,
    unit, newLen, i;
    queueFrame(draw);


    time = new Date().getTime();
    ellapsed = time - lastTime;
    lastTime = time;

    delta = ellapsed / duration;
    context.clearRect(0, 0, fullWidth, fullHeight);
    toRender = [];

    for (i = 0; i < points.length; i++) {
        point = points[i];
        point.theta += delta * Math.PI * 2.0 * point.speedModifier * closeness;
        if (point.theta > Math.PI * 2.0) {
            point.theta -= Math.PI * 2.0;
        }
        matrices = getRotationMatrices(point.axisPoint, point.theta);
        location = transform(point.currentPoint, matrices);

        len = Math.sqrt(location[0] * location[0] + location[1] * location[1] + location[2] * location[2]);
        unit = [location[0] / len, location[1] / len, location[2] / len];
        newLen = len + (maxWidth - len) * (1.0 - closeness) * 0.3;
        location = [unit[0] * newLen, unit[1] * newLen, unit[2] * newLen];

        point.location = location;

        toRender.push(point);
    }

    //TODO: do radix sort here, probably
    toRender.sort(function (p1, p2) {
        if (p1.location[2] < p2.location[2]) {
            return -1;
        } else if (p1.location[2] > p2.location[2]) {
            return 1;
        }
        return 0;
    });

    context.font = "35pt Verdana";

    context.globalAlpha = 1.0;
    fill = "hsl(0, 0%, 95%)";
    context.fillStyle = fill;
    context.strokeStyle = 'hsl(0, 0%, 75%)';
    context.lineWidth = 10;
    context.beginPath();
    context.arc(centerX, centerY,
    halfWidth - 20, 0, 2 * Math.PI, false);
    context.fill();
    context.stroke();

    location = toRender[0].location;
    if (location[2] > 0) {
        context.strokeStyle = 'hsl(210,50%,' + Math.round(40 + 20 * (1 - closeness)) + '%)';
        context.fillStyle = 'hsl(210,50%,' + Math.round(60 + 20 * (1 - closeness)) + '%)';
        context.textBaseline = 'middle';
        context.lineWidth = 2;
        width = context.measureText(points.length.toString()).width;

        context.fillText(points.length.toString(), centerX - (width / 2.0), halfWidth);
        context.strokeText(points.length.toString(), centerY - (width / 2.0), halfHeight);
    }


    for (i = 0; i < toRender.length; i++) {
        context.globalAlpha = Math.min(1.0, 0.2 + closeness);
        location = toRender[i].location;
        size = ((location[2] - (-halfWidth)) / fullWidth) * 8.0 + 2.0;
        opacity = ((location[2] - (-halfWidth)) / fullWidth) * 0.8 + 0.2;

        l = Math.round((0.9 + -0.6 * opacity) * 100.0);
        h = toRender[i].color[0];
        s = toRender[i].color[1];

        fill = 'hsl(' + h + ',' + s + '%,' + l + '%)';
        context.fillStyle = fill;
        context.beginPath();
        context.arc(location[0] + centerX,
        fullHeight - (location[1] + centerY),
        size, 0, 2 * Math.PI, false);
        context.fill();

        if (location[2] <= 0 && ((i == toRender.length - 1) || (toRender[i + 1].location[2] > 0))) {
            context.globalAlpha = 1.0;
            context.strokeStyle = 'hsl(210,50%,' + Math.round(40 + 20 * (1 - closeness)) + '%)';
            context.fillStyle = 'hsl(210,50%,' + Math.round(60 + 20 * (1 - closeness)) + '%)';
            context.textBaseline = 'middle';
            context.lineWidth = 2;
            width = context.measureText(points.length.toString()).width;

            context.fillText(points.length.toString(), centerX - (width / 2.0), halfWidth);
            context.strokeText(points.length.toString(), centerY - (width / 2.0), halfHeight);
        }
    }
}

The differences are:

  • We loop through all our points and increment their orbit rotation angle by our delta value, which we determine based on the elapsed time from the previous frame, and the desired total duration of a revolution. We also generated some random speed modifiers for each point, so this gets factored in too. This is so that not every point appears to be orbiting at the same velocity.
  • For that matter, why not slow down the orbits depending on how close the cursor is to the center of the canvas? For this reason we also use closeness as a scaling factor.
  • We get the transformation matrices for the point, and then transform it in 3d space to get its actual location.
  • We adjust the 3D distance from the center based on our closeness variable, so that as the cursor gets closer to the center of the canvas, the circles appear to explode away from the center.
  • We collect each point into an array which we will sort by depth.

Then we sort the array of points by depth so that we can interleave the text element at the correct depth level when rendering. This is just a default JS Array sort, a radix sort would be more efficient in this context, but we aren’t really dealing with a lot of points.

Given the sorted list of points, for each point we:

  • Adjust the composition opacity of our point based on our closeness variable. This is so that the points fade as the cursor gets closer to the center of the canvas.
    • Setting the global alpha affects the opacity modifier as shapes are rendered into the canvas. This is an additional opacity scaling factor in addition to any alpha component we might be adding to our fill or stroke colors.
  • Scale the size and the lightness of each circle based on the z depth of the point. This is so that the points get lighter and smaller (as if they are receding into a fog) when they get toward the rear of the space.
  • Then we set the fill style to the calculated color, and draw a circle and fill it.
  • If we are transitioning between rendering circles behind the text to circles in front of the text, then we render the text element in the center.

And given all this work, we have this! Pretty neat, huh? If you load the fiddle you can play around with making adjustments and see it all in motion.