Bounded Panning in D3

Say you want to draw a chart but the data that you’re dealing with has a huge domain. You can’t possibly fit the whole chart on your page without degrading the visual fidelity of the data, so you’re left with something that looks like this.

You can’t see all your data. This is a problem, and it’s a problem that clearly manifests itself in your code. Check out how you’re setting up your scales and how you’re using them. It probably reads something like this.

var xscale = d3.scale.linear().domain([0, max]).range([0, max]),
    yscale = d3.scale.linear().domain([0, 100]).range([height, 0]);

var line = d3.svg.line()
    .x(function(d) { return xscale(d[0]); })
    .y(function(d) { return yscale(d[1]); })
    .interpolate('basis');

svg.append('g')
    .datum(points)
  .append('path')
    .attr('class', 'data')
    .attr('d', line);

The scale for the x-axis is more or less an identity scale; both the domain and range are defined in terms of max, the maximum value of your data’s domain. Your scale doesn’t take into account the width of your page.

You can pick from several visualization techniques to solve this problem. The simplest most straightforward solution is to add a panning behavior to your chart so you can move sections of your data into and out of view. D3 comes with the d3.behavior.zoom component, which interprets mouse and touchpad events on an element as panning and zooming gestures, and updates the scales associated with the behavior accordingly. With a little configuration, this component will provide the horizontal panning behavior you want.

Make The Chart Behave

First, you need to create a new d3.behavior.zoom component and disable its zooming functionality. You do it this way.

var zoom = d3.behavior.zoom().scaleExtent([1, 1]);

The scaleExtent method sets a lower bound and upper bound on the zoom component’s scale factor. Since you don’t want any zooming, the scale factor should never change from its initial value, which is 1, so you should set the lower bound and upper bound of the scale factor to 1.

Next you need to configure zoom to only interpret horizontal panning gestures. You do it this way.

zoom.x(xscale);

Now every time you try to manipulate your chart, the zoom behavior will automatically update your xscale. Your intention to pan will be reflected in the appearance of your chart once you redraw it.

Finally, you need to detect when panning and zooming gestures occur so that you can redraw your chart. The zoom behavior allows you to register a callback on a single event called 'zoom'.

zoom.on('zoom', function() { 
  svg.select('.data').attr('d', line);
});

You should redraw your data in the 'zoom' event callback handler. Any visual elements that depend on xscale should be redrawn as well. If you’re using the reusable charts pattern, everything will be updated appropriately by just calling your chart again.

This is what it looks like when you put it all together and attach your configured zoom behavior to your chart’s SVG container.

var zoom = d3.behavior.zoom()
    .scaleExtent([1, 1])
    .x(xscale)
    .on('zoom', function() {
      svg.select('.data').attr('d', line)
    });

svg.call(zoom);

Now you have the ability to see all of your data, if not all at once, then piece by piece by clicking and dragging your chart left and right. Try it out on the chart below. ☟ They’re all interactive from this point onward.

There’s a problem, though. There’s no limit on how much the zoom behavior will translate your data to the left or to the right. You can make your data fly off the screen if you pan hard enough. You can fix this by using zoom’s translate() method, which is a getter-setter for the current translation vector.

var t = zoom.translate(),
    tx = t[0],
    ty = t[1];

What you need to do is first figure out how much translation you should allow in either direction, and then you need to figure out a way to enforce that limit.

How Much Is Too Much?

The domain of your data starts at 0, and so does the beginning of your chart’s x-axis. When you pan to the right, there’s no additional data to display, and your data looks like it’s floating in the middle of nowhere. So what you want to do is prevent panning to the right from your chart’s initial state. In other words, tx should never take on a positive value.

tx = Math.min(tx, 0);

The other direction seems more complicated, but it’s not. You want to allow some panning to the left, since there’s some hidden data in your chart. If you allow too much left panning, you’re left with the same problem as before. How much is too much?

Well, how much of your data isn’t visible? That’s the same question you asked yourself when you figured out how to bound panning to the right, and that’s the question now. The only difference is that it’s the opposite direction, and therefore a lower bound on the translation rather than an upper bound.

The amount of your data that’s not visible is the difference between the width of your chart and the maximum value of the domain of your data. So tx should never be less than the difference between the width of your chart and the maximum values of your data’s domain.

tx = Math.max(tx, width - max);

Now you want to update the zoom behavior with this bounded translation before redrawing your chart.

zoom.translate([tx, ty]);

All together, your zoom event handler should look something like this.

zoom.on('zoom', function() {
  var t = zoom.translate(),
      tx = t[0],
      ty = t[1];

  tx = Math.min(tx, 0);
  tx = Math.max(tx, width - max);
  zoom.translate([tx, ty]);

  svg.select('.data').attr('d', line);
});

And now you have a well-behaved chart.