Visualizing with canvas

The history of the HTML canvas tag is hilariously contentious.

In 2005, Apple introduced the tag for the first time as a part of their short-lived "dashboard widgets" feature. People were upset: without consulting anyone or making any attempt to standardize, Safari added an entirely new tag. A few years later, everyone realized it was actually a pretty good idea, and canvas entered the HTML5 spec.

Canvas lets us create bitmap images (meaning, composed of a grid of pixels) using a relatively simple drawing API. Compared to SVG, performance is relatively high, but it comes at a cost of abstraction: event handling, drawing state, and animations must all be manually programmed. When writing code that uses canvas, it may be worth it to quickly benchmark whether your requirements could be met with DOM and SVG techniques first, before shouldering the extra burden of writing interactive canvas code.

A little context

Canvas is a powerful tool, but we don't interact with it directly. This in part is because it only represents a bucket of pixels, and there might be multiple ways of setting those pixels. For example, browsers may support WebGL, which also draws to a canvas but uses a very different (and much more cumbersome) API to do so. So our first job is to ask the canvas for a rendering context, specifying that we want to use the regular 2D functions for drawing:

var canvas = document.querySelector("canvas");
var context = canvas.getContext("2d");

One way to think about the distinction between the canvas and the context is the difference between a computer screen and a copy of Photoshop: the former provides a space for the latter to actually draw and render graphics. Just like the screen, we also need to set the "resolution" of our canvas, which goes through its width and height properties.

<canvas width="640" height="480">
  Fallback HTML can go here, but
  realistically, no modern browser
  needs a fallback.
</canvas>

Whenever possible, you should size canvas using CSS, not using the attributes, and then resize its internal display buffer to match. If you do not do this, the default size for your canvas is 300x150. Conveniently, setting the width and height of a canvas element will also clear its contents, so it's a good thing to do at the top of your render loop:

var renderLoop = function() {
  // set the size to match CSS and clear contents
  canvas.width = canvas.clientWidth;
  canvas.height = canvas.clientHeight;
  
  // drawing code goes here

  // schedule the next animation frame
  requestAnimationFrame(renderLoop);
};

renderLoop();

Once that's done, however, we'll use the context object to perform all actual drawing. Here's some simple code to draw a red rectangle with a blue outline.

// any CSS color string is permitted
context.fillStyle = "red";
context.strokeStyle = "blue";
// start a path
context.beginPath();
// draw around the four corners
context.moveTo(0, 0);
context.lineTo(10, 0);
context.lineTo(10, 10);
context.lineTo(0, 10);
context.closePath();
// actually stroke and fill the shape
context.stroke();
context.fill();

A 2D context is what we call a "state machine API," in the sense that it retains a degree of global state between calls, and uses that to do its drawing. Settings like the fill or stroke style (the aptly-named fillStyle and strokeStyle properties) are persistent across drawing instructions—you set them once, and they continue in effect until they're altered. This is in contrast to an API where these settings are done explicitly for each function call (say, where drawing a rectangle takes a color argument in addition to size and position).

Another part of the global state is the path: when we call beginPath() in the code above, the context starts tracking drawing instructions, but it doesn't actually render anything out to the screen until the stroke() and fill() calls are made. There's a good reason for this—it's more efficient to build up a complex shape from individual points, and then color it in with a single instruction—but it does mean that you should get into a routine whenever you use canvas:

Let's take a look at how to put these things together, by designing a version of everyone's worst dataviz nightmare: the jiggling New York Times election dial.

Content warning: 2016 election

In 2016, the NYT rolled out their election results page, complete with a set of dials showing the predicted popular vote. To reflect the margin of error at any given time, the needle of the dial wavered back and forth. It was incredibly nerve-wracking, but it does make for a great canvas demo. Here's our initial markup to create the canvas tag itself. Note that I leave the width and height off, because we're just going to set that every frame from JavaScript, and the actual size will be set from CSS.

<canvas class="dial"></canvas>

In our JavaScript, we're going to get our canvas and its context, and then define a render function that will repeatedly schedule itself to be run every frame. Functions called with requestAnimationFrame are passed a high-precision timestamp as their initial parameter, which is useful for creating animations. Not shown is the actual election results data, which is updated separately from the animation (say, from a regular network request to the server). Currently, the render function just clears the canvas by resizing it to match its physical dimensions.

var canvas = document.querySelector(".dial");
var context = canvas.getContext("2d");

var render = function(t) {
  canvas.width = canvas.clientWidth;
  canvas.height = canvas.clientHeight;

  requestAnimationFrame(render);
};

requestAnimationFrame(render);

Now we'll need to set up our backdrop for the needle to move against, using a set of four arcs (dark red, light red, light blue, and dark blue). When you have a repeated graphic element like this, it's nice to be able to defined it from data, so that you don't have to write code for every single path. In this case, I'm defining the arcs as arrays, containing their start, end, and color values. Note that I can set the line width for the stroke before the loop, and simply leave it set for all of the arcs.

// inside of render(), after clearing canvas
var arcs = [
  [ 1.25, 1.375, "rgba(255, 0, 0, .5)" ],
  [ 1.375, 1.5, "rgba(255, 0, 0, .2)" ],
  [ 1.5, 1.625, "rgba(0, 0, 255, .2)" ],
  [ 1.625, 1.75, "rgba(0, 0, 255, .5)" ]
];

//draw the background arcs
context.lineWidth = canvas.width * .1;
arcs.forEach(function(arc) {
  var [ start, end, color ] = arc;
  context.beginPath();
  // arc lets us draw a part of a circle
  context.arc(dialCoords.x, dialCoords.y, dialCoords.r, Math.PI * start, Math.PI * end);
  context.strokeStyle = color;
  context.stroke();
});

If you haven't used trig since high school, the use of Pi in the arc function above (and the sine/cosine functions we'll use below) might seem a little scary. No worries! All you need to know is that these functions act on radians, and there are 2 * Pi radians in a circle. That means that you can treat the circle as a range from 0 to 2, starting at the rightmost edge and proceeding clockwise, and we just have to remember to multiply by Pi before drawing.

With that out of the way, we'll draw our needle, which is a fairly primitive line from the center of the dial to 120% of its radius. To do so, we'll add the error (multiplied by a sine wave to create jiggle) to the result value, and then scale it to match our dial (using a bounds object that runs from 1.25 to 1.75 times Pi). This math gets a little hairy, but it's fairly short.

// figure the needle position
// value is Math.sin() * the error rate + the current scaled value
var jiggle = Math.sin(t / 100) * result.margin;
var value = result.value + jiggle;
// now adjust to the visual range
// it starts at 1.25 radians and extends for another .5 radians
// that means it's a quarter of a circle
var scaled = value / 100 * .5 + 1.25;


// draw the needle
context.beginPath();
context.moveTo(dialCoords.x, dialCoords.y);
// x/y is the line's endpoint, using sin/cos to walk around the circle
var x = Math.cos(scaled) * dialCoords.r * 1.2 + dialCoords.x;
var y = Math.sin(scaled) * dialCoords.r * 1.2 + dialCoords.y;
context.lineTo(x, y);
context.lineWidth = 2;
context.strokeStyle = "black";
context.stroke();

The Math.sin() and Math.cos() functions convert a measurement in radians (which, remember, goes from 0 to 2 around the circle) to the vertical and horizontal position of a point at that angle, respectively. By taking our scaled value, which ranges from 1.25 radians to 1.75 radians, and feeding it to these trig functions, we get the position on a unit circle, which we then multiply by our distance (1.2x the circle's radius) to find the final endpoint of the needle.

Ultimately, though, don't worry too much about the trigonometry. The important parts of this code are the structure of the rendering loop, the correct order of draw operations, and managing context state properly. If you can manage those, you're well on your way.

Events without DOM

All of the examples above have involved an animation loop, where the render code is called continually using requestAnimationFrame. It's worth noting that you can use canvas to render static images as well: it's perfectly fine to just draw something once and then move on, as we did for these contribution graphs. Alternately, you can choose to redraw only when something changes, instead of continuously updating. For example, you might update the canvas when notified of a user event, like a mouse or touch interaction.

The tough part of handling events when using canvas is that you no longer have distinct "targets" to which you can attach listeners. Visually, there may be objects drawn onscreen, but they're just pixels in the image buffer, not actual elements. How do we react appropriately to clicks or taps? Let's break this problem down into two parts to solve it: finding the location of the event relative to the canvas, and then strategies for hit detection.

Our first problem is to figure out where on the canvas the event occurred. Unfortunately, this is not a simple way to solve across mobile and desktop browsers. Most modern browsers support the offsetX/Y properties on mouse events (it finally shipped in Firefox in mid-2015). However, touch events do not provide an offset from the targeted element, probably because touch movement is treated much differently from mouse movement. Instead, we have to recreate it, using getBoundingClientRect():

// expects an event with clientX/Y properties
var listener = function(e) {
  var bounds = canvas.getBoundingClientRect();
  // these coordinates are relative to the canvas
  var x = e.clientX - bounds.left;
  var y = e.clientY - bounds.top;
};

// register for mouse directly
canvas.addEventListener("click", listener);

// touch events hide this info in the touches list
// extract first touch and pass it on
canvas.addEventListener("touchstart", e => listener(e.touches[0]));

We should note that on certain touch events (such as movement), the list of finger contacts is called changedTouches instead of just touches, because Apple delights in making these APIs as precious as possible at the cost of usability. Regardless, once we have a position that's in the same coordinate space as our canvas, we can convert back from that pixel location into some kind of meaningful event target.

The classical method for handing this kind of thing, indeed the way that the browser itself handles it, is by creating a render tree in which display objects can contain other display objects, and each specifies its position in the coordinate system. The same process is then used for both drawing and input: walk the tree, rendering or testing as we go, until either drawing is complete or you find the deepest element at the input coordinates. This is how the DOM works, as well as how older technologies like Flash worked, and how frameworks like ThreeJS display a scene.

For interactives, however, this is usually overkill. Most of the time, our needs are (or should be) much simpler. For example, in a bar graph, we might ask ourselves which column is currently closest to the mouse, then highlight and show detail for that specific column. In this case, we don't really care about how high the mouse is—there's no value in making the reader specifically mouse over the bar itself. We just care whether the mouse is over the graph area, and if so, what the index of the closest column would be based on its horizontal position.

For the purposes of this example, let's assume a few things:

So with those givens, we're going to perform two tasks. First, we'll perform a basic scissor test to see if the mouse event is actually within the graph, and not in the padding area. Second, if it's on the actual graph, we'll basically invert a scaling function: instead of multiplying a data point to get screen coordinates, we'll divide and round down to convert screen position into an index instead.

// scissor test
var pad = 20;
if (
  x < padding || x > canvas.width - padding ||
  y < padding || y > canvas.height - padding
) {
  // redraw, but without a highlight
  render();
} else {
  // find the column we're in using the floor
  var index = Math.floor((x - padding) / data.length);
  render(index);
}

Not every interactive can be reduced to such a simple code path. But in my experience, it's much easier to treat input as a relatively imprecise and crude variable, especially on touch screens, rather than spend a lot of time doing intersection tests—especially on deadline.