JS Tip of the Day: Using Generators to Animate

Using Generators to Animate
Version: ES2015
Level: Intermediate

Sometimes you want to animate things that weren’t built to be animated. As an example, take this simple bubble sort function that will sort the elements of an array-like object from smallest to largest.

function bubbleSort (list) {
    let n = list.length - 1;
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < n; j++) {
            if (list[j] > list[j + 1]) {
                [list[j], list[j + 1]] = [list[j + 1], list[j]];
            }
        }
    }
}

This bubbleSort() takes an input and, in one fell swoop, immediately sorts it. If you wanted to create a visualization to animate this process over time, you’d need to somehow break this appart and call it piece by piece over multiple frames. Thankfully, this is quite easy to do using generators.

Because generator functions allow you to effectively pause and continue the execution of the function at places where it yields, it can make it really easy to take an existing function like bubbleSort() above, convert it to a generator, and then distribute it out over multiple frames in small chunks. All you need to do is yield where an animation frame should exist. Here is a converted bubbleSort():

function * bubbleSort (list) { // now a generator function
    let n = list.length - 1;
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < n; j++) {
            if (list[j] > list[j + 1]) {
                [list[j], list[j + 1]] = [list[j + 1], list[j]];
                yield; // pause here until told to continue
            }
        }
    }
}

At this point it’s just a matter of animating it over time. Since we don’t want this to happen too fast, a setInterval() can be used to break down it down to slower steps compared to those we might get with a requestAnimationFrame() loop. Inside the setInterval() callback the bubble sort generator’s next() method would be called for progressing the sort by one step at a time while the results are shown on the screen.

renderToScreen(); // initial, unsorted render
let sortGen = bubbleSort(list); // generator to walk through
let sortInterval = setInterval(() => {
    let result = sortGen.next(); // next step of the sort
    if (result.done) {
        clearInterval(sortInterval);
    } else {
        renderToScreen(); // render for current step
    }
}, 250);

For a complete, working example:

let list = [70, 120, 50, 140, 130, 80, 110, 150, 90, 100, 60];

function * bubbleSort (list) {
    let n = list.length - 1;
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < n; j++) {
            if (list[j] > list[j + 1]) {
                [list[j], list[j + 1]] = [list[j + 1], list[j]];
                yield;
            }
        }
    }
}

function renderToScreen () {
    // nothing fancy, full wipe and redraw
    document.querySelectorAll('div')
        .forEach(el => el.remove());

    for (let value of list) {
        let el = document.createElement('div');
        Object.assign(el.style, {
            display: 'inline-block',
            margin: '1px',
            width: '7px',
            height: value + 'px',
            backgroundColor: 'blue'
        });
        document.body.appendChild(el);
    }
}

renderToScreen();
let sortGen = bubbleSort(list);
let sortInterval = setInterval(() => {
    let result = sortGen.next();
    if (result.done) {
        clearInterval(sortInterval);
    } else {
        renderToScreen();
    }
}, 250);

Given that this is tip #100, lets keep this going and look at another example, this time one using canvas. The following uses a function to draw a circle with straight lines of varying degrees accuracy using a steps argument.

let canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
document.body.appendChild(canvas);
let context = canvas.getContext('2d');

function drawCircle(context, steps, thickness = 2) {
    context.lineWidth = thickness;
    let { width, height } = context.canvas;
    let x = width / 2;
    let y = height / 2;
    let radius = Math.min(width, height) / 2 - thickness;
    let angle = 0;

    let pathX = x + Math.cos(angle) * radius;
    let pathY = y + Math.sin(angle) * radius;
    context.beginPath();
    context.moveTo(pathX, pathY);

    for (let step = 0; step < steps; step++) {
        angle = step * Math.PI * 2 / steps;
        pathX = x + Math.cos(angle) * radius;
        pathY = y + Math.sin(angle) * radius;
        context.lineTo(pathX, pathY);
    }
    context.closePath();
    context.stroke();
}

drawCircle(context, 16);

Again, this is a synchronous, do-it-all-at-once operation that we’re going to convert into an animation that will draw itself over time. Unlike with the bubble sort example, however, canvas drawings provide some additional challenges.

This big difference here is that each step in the loop is not self contained. The loop is being used to generate paths which is only the first step of the drawing process. In addition to that is stroking the path so it can be made visible on screen. But this only happens once, and only after the loop is complete. As is, breaking up the loop into individual steps would not draw anything until the very last step.

To fix this, one option is to make each step of loop the perform a complete drawing but only a drawing of the current line for that loop iteration. Without yet bringing generators into the mix, that would look something like:

function drawCircle(context, steps, thickness = 2) {
    context.lineWidth = thickness;
    let { width, height } = context.canvas;
    let x = width / 2;
    let y = height / 2;
    let radius = Math.min(width, height) / 2 - thickness;
    let angle = 0;

    let pathX = x + Math.cos(angle) * radius;
    let pathY = y + Math.sin(angle) * radius;
    for (let step = 0; step <= steps; step++) {
        context.beginPath(); // each loop draws new path
        context.moveTo(pathX, pathY);
        angle = step * Math.PI * 2 / steps;
        pathX = x + Math.cos(angle) * radius;
        pathY = y + Math.sin(angle) * radius;
        context.lineTo(pathX, pathY);
        context.stroke(); // each loop strokes its own path
    }
}

Now, each loop draws its own, independent line. While this would seem to fix everything (and in some cases would), there is a problem that you may notice in certain situations. This becomes apparent when working with alpha and larger line thicknesses. If you run the function using the following, you’ll see what I mean.

context.globalAlpha = 0.5;
drawCircle(context, 16, 20);

Because each line is its own stroke, it’s no longer able to render as a contiguous line with joins and each line will end up blending with others where they overlap.

The solution here is to, instead of drawing each line independently, clear the canvas for each line and redraw the path again in full up to and including the current line in the loop. Luckily, clearing the canvas doesn’t also wipe out the stroke state, so the path will remain intact even though any previous drawing of the path on the canvas gets cleared.

function drawCircle(context, steps, thickness = 2) {
    context.strokeStyle = 'black';
    context.lineWidth = thickness;
    let { width, height } = context.canvas;
    let x = width / 2;
    let y = height / 2;
    let radius = Math.min(width, height) / 2 - thickness;
    let angle = 0;

    let pathX = x + Math.cos(angle) * radius;
    let pathY = y + Math.sin(angle) * radius;
    context.beginPath(); // only one path
    context.moveTo(pathX, pathY);

    for (let step = 0; step < steps; step++) {
        context.clearRect(0, 0, width, height); // clear canvas
        angle = step * Math.PI * 2 / steps;
        pathX = x + Math.cos(angle) * radius;
        pathY = y + Math.sin(angle) * radius;
        context.lineTo(pathX, pathY);
        context.stroke(); // strokes all paths up to here
    }

    context.clearRect(0, 0, width, height); // clear canvas
    context.closePath();
    context.stroke(); // strokes full, closed path
}

Now we can make the conversion to a generator and spread the drawing out over a number of frames in an animation. The steps argument, since it drives the looping, will now also determine the speed at which the animation occurs. Completed example, this time using requestAnimationFrame():

let canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
document.body.appendChild(canvas);
let context = canvas.getContext('2d');

function * drawCircle(context, steps, thickness = 2) {
    context.lineWidth = thickness;
    let { width, height } = context.canvas;
    let x = width / 2;
    let y = height / 2;
    let radius = Math.min(width, height) / 2 - thickness;
    let angle = 0;

    let pathX = x + Math.cos(angle) * radius;
    let pathY = y + Math.sin(angle) * radius;
    context.beginPath();
    context.moveTo(pathX, pathY);

    for (let step = 0; step < steps; step++) {
        context.clearRect(0, 0, width, height);
        angle = step * Math.PI * 2 / steps;
        pathX = x + Math.cos(angle) * radius;
        pathY = y + Math.sin(angle) * radius;
        context.lineTo(pathX, pathY);
        context.stroke();
        yield;
    }

    context.clearRect(0, 0, width, height);
    context.closePath();
    context.stroke();
}

function circleLoop () {
    let result = circleGen.next();
    if (!result.done) {
        requestAnimationFrame(circleLoop);
    }
}

let circleGen = drawCircle(context, 200);
requestAnimationFrame(circleLoop);

More info:


More tips: JavaScript Tips of the Day