Michael Wooley's Homepage

Drawing With D3.js Part 2: Zooming and Panning

This post adds features to the drawing app that we started in part 1. In particular, we’re going to make it easy to zoom and pan the canvas. Again D3.js will simplify our job substantially. In fact, there is a built-in feature/command (d3-zoom) that would ordinarily allow us to zoom with a scrolling/pinching gesture and pan with a click-and-drag gesture. The main wrinkle that we’re going to encounter is that the click-and-drag gesture is already taken: we click and drag to add rectangles. Our job, then, will consist in figuring out a way to juggle competing event listeners.

Here’s what we’re going to create:

Click + drag → Add rectangle.
Shift + Click + Drag → Pan Canvas.
scroll → Zoom.
See the full sample code at this gist.

As you can see from the demo, we’re going to implement canvas panning by making it into a Shift + Click + Drag gesture. To do this, we’re going to add some new code and reorganize some of our earlier code. In particular we’ll:

  • Introduce a zoom <g>roup element that nests all elements that are subsequently added to the canvas. We’ll carry out zooming and panning by transforming this element.
  • Add a d3.zoom behavior to the canvas. We’ll need to turn on the scroll gestures and turn off event listeners for the pan behavior.
  • Modify the drag behavior on the canvas to route drag gestures. If the shift key is pressed, then the canvas should pan. If it is not, then a new rectangle should be created.

Let’s get to it.

Restructuring the Event Listeners

In the previous post I added an event listener for a click-and-drag gesture using off-the-shelf methods from d3. For this post I’m going to need to dig a bit deeper into these methods to get the behavior that I want.

Drag Routing

Recall that we specified a drag behavior via something like this:

this.svg.call(
  d3.drag()
  .on('start', self.dragBehavior.start)
  .on('drag', self.dragBehavior.drag)
  .on('end', self.dragBehavior.end)
);

In the previous post we specified that our dragBehavior methods were from SVGCAnvas.addRect. That is, at the start of the drag we called the code to create the rectangle and as we dragged the the .drag method changed the rectangle dimensions.

For this post we need to differentiate between a “vanilla drag” and a drag in which the shift key is compressed. To do this we’ll create an additional set of methods (stored in SVGCanvas.dragBehavior) that will route the drag behavior. Here’s some pseudo-code demonstrating the basic idea:

dragBehavior = function() {
  if ( Detect no shift key ) {
    // Add and modify a rectangle
  }
  if ( Detect shift key ) {
    // Pan the Canvas
  }
}

In short, we’re nesting an event listener in an event listener. The exact code is as follows:

 1 SVGCanvas.prototype.makeDragBehavior = function() {
 2   var self = this;
 3 
 4   var start = function() {
 5     if (!d3.event.sourceEvent.shiftKey) {
 6       self.addRect.start();
 7     }
 8     if (d3.event.sourceEvent.shiftKey) {
 9       null;
10     }
11   }
12 
13   var drag = function() {
14     if (!(self.Rect.r === null) && !(d3.event.sourceEvent.shiftKey)) {
15       self.addRect.drag();
16     }
17     if (d3.event.sourceEvent.shiftKey) {
18       self.zoomPan.pan();
19     }
20   }
21 
22   var end = function() {  if (!(self.Rect.r === null) &
23   !(d3.event.sourceEvent.shiftKey)) { self.addRect.end(); } if
24   (d3.event.sourceEvent.shiftKey) { null; } }
25 
26   self.dragBehavior = {
27     start: start,
28     drag: drag,
29     end: end
30   };
31 }

The d3.event.sourceEvent.shiftKey returns true if the shift key is compressed. There are a number of other such properties for the alt key, etc. If the key is compressed, then–in the drag method–we call a new method called self.zoomPan.pan. This is the method that we will use to pan the canvas; it will be discussed further below. When defining the drag and end methods we have the additional condition !(self.Rect.r === null). This condition checks for an edge case. To be specific, the case arises if the user 1.) Clicks down while holding the shift key, 2.) releases the shift key but not the click, and 3.) drags. Without the condition the addRect.drag method would either be called on the last rectangle created–changing the coordinates of that rectangle–or (in the case where no rectangles have been created) we would get an error.

As was the case with the SVGCanvas.addRect methods, I have defined an enclosing method SVGCanvas.makeDragBehavior in order to define the methods and set the context. This method will have to be called from the SVGCanvas constructor function.

Zoom Listener

The zoom listener is going to be less elaborate than the drag listener but we’ll still have to dig into the d3.zoom api a bit. Here’s the code that we need to add to the SVGCanvas constructor object:

 1 this.svg.call(
 2     d3.zoom()
 3     .scaleExtent([1, 10])
 4     .on('zoom', this.zoomPan.zoom)
 5   )
 6   .on('mousedown.zoom', null)  // For rodent lovers
 7   .on('mousemove.zoom', null)
 8   .on('mouseup.zoom', null)
 9   .on('touchstart.zoom', null) // For our smartphone-toting friends
10   .on('touchmove.zoom', null)
11   .on('touchend.zoom', null);

As with the drag behavior, we add a call to the svg element. Lines 2-4 specify a “normal” zoom operation. That is, it attaches a zoom behavior with d3.zoom(). With .scaleExtent([1, 10]) we modify the zoom behavior so that the minimal zoom is “1” and the maximal zoom is “10”. We’ll get into what exactly “1” and “10” mean below. In short, this is the number that enters the scale(#) specification in the transform attribute. Finally, line 4 attaches a method this.zoomPan.zoom (discussed below) that will be used to zoom in and out.

In lines 6-11 we are basically undoing a lot of the default behavior of d3.zoom. Usually, d3.zoom is a method for both panning and zooming. We need to override the default panning behavior in order to route drag events to our homespun drag behavior. Let’s break down line 6 in detail. In general, the .on([event], [callback]) method specifies what should occur (the callback) whenever an event is detected on an element. Here, mousedown.zoom is a special sort of event: a “mousedown” (i.e. downward click) associated with the zoom event (which we’ve just defined). The default behavior in this case is to start a canvas pan that would override all other mousedown events. Here, however, we specify the callback to be null. That is, we specify that these events should be ignored. Each of lines 7-11 similarly nullify other event listeners associated with d3.zoom.

The Zoom Group and Transformations

In this section I’m going to set up some new infrastructure to make zooming simple. We’re going to start out by wrapping everything in a <g>roup element. By manipulating the transform attribute of the <g> element we’ll be able to translate, rotate, scale, or skew all of the elements contained in the <g> element simultaneously. Next, we’re going to need to make some modifications to account for the fact that transforming the canvas messes up our coordinate system. We’re going to need to modify our method for detecting the mouse position This is the previously-trivial mouseOffset method. to account for this.


Aside: <g transform="transpose(30,2) scale(2)">

While I briefly introduced the <g> element in the last post, I didn’t do anything with it. <g> elements are sort of like <div>s for SVGs. They don’t really do anything, they just organize elements into, well, groups. Groups are uniquely well-suited for transformations. Here is the basic gameplan: We’ll define a group and place all of our other elements within it. For example:

<svg height="250" width="250">
  <g class="zoom-group">
    <rect x="94" y="112" width="160" height="120" class="rect-main"></rect>
    <rect x="186" y="178" width="144" height="76" class="rect-main"></rect>
    <rect x="132" y="148" width="88" height="60" class="rect-main"></rect>
  </g>
</svg>

Now, we can translate, scale, skew, or rotate all of these elements simultaneously by manipulating the transform attribute of <g class="zoom-group">. Consider:

<svg height="250" width="250">
  <g class="zoom-group" transform="translate(-128,-80) scale(2)">...</g>
</svg>

Here, we’ve:

  • Shifted/translated the canvas:
    • 128 units to the right (i.e. leftward mouse movement).
    • 80 units to the down (i.e. upward mousemovemt).
  • Zoomed/scaled the canvas so that the elements inside the group are displayed at twice their ordinary size.

Panning and zooming affects all elements in the canvas. However, only the transform attribute of the <g> element changes. Notice that the <rect> element does not change.

The transform attribute is quite powerful but also rather complicated. Refer to the Mozilla reference page or this nice write-up by Sara Soueidan for more information.


Zoom Group: Implementation

Let’s add a zoom group to the SVGCanvas constructor:

function SVGCanvas(options) {
  // Previous...

  // Canvas
  //// Make the main container SVG
  this.svg = d3.select(this.options.addTo)
    .append('svg')
    .attr('height', this.options.h)
    .attr('width', this.options.w)
    .attr('class', 'display-svg');
  //// Add border if requested  <= NOT INCLUDED IN ZOOM GROUP
  if (this.options.addBorderRect) {
    this.svg.append('rect')
      .attr('height', this.options.h)
      .attr('width', this.options.w)
      .attr('stroke', 'black')
      .attr('stroke-width', 4)
      .attr('opacity', 0.25)
      .attr('fill-opacity', 0.0)
      .attr('class', 'border-rect');
  }
  //// Add zoom and pan group
  this.zoomG = this.svg
    .append('g')
    .attr('class', 'zoom-group');

  // Rectangles
  // More...
}

Here, we’ve append a <g> element to this.svg and defined a new property, this.zoomG. Notice that we don’t include the border rectangle element in the zoom group. Thus, any transformations that are applied to the zoom group will not affect the border rectangle, which is what we want.

We do want the rectangles that we draw to be affected by the transformations, however. To do this, we need to modify the SVGCanvas.addRect.start method so that the new <rect> elements are appended to the zoom group element rather than the <svg>. We can do this by changing line 11 below:

 1 SVGCanvas.prototype.makeAddRect = function() {
 2   // Methods for adding rectangles to the svg.
 3   var self = this;
 4   start = function() {
 5     //Add a rectangle
 6     // 1. Get mouse location in SVG
 7     var m = self.mouseOffset();
 8     self.Rect.x0 = m.x;
 9     self.Rect.y0 = m.y;
10     // 2. Make a rectangle
11     self.Rect.r = self.zoomG // <= self.zoomG, NOT self.svg
12       .append('g')
13       .append('rect')
14       .attr('x', self.Rect.x0)
15       .attr('y', self.Rect.y0)
16       .attr('width', 1)
17       .attr('height', 1)
18       .attr('class', 'rect-main')
19     ;
20   }
21 
22   // More ...
23 }

That’s it!

Mouse Coordinates

The main thing to be aware of here is that all of this panning and zooming business will mess with your attempts to detect where the mouse click occurs. The problem is twofold: First, you want to use the mouse position to specify the location of the added rectangles relative to a single, common coordinate system. That is, a rectangle should have the same x/y and height/width attributes whether it is drawn at 2x zoom with a bunch of panning or 1x zoom. Second, we want the information/drawing that appears on the screen to reflect whatever screwy, transformed coordinate system we’re currently working in.

The manual way of doing this is to get the mouse coordinates off of the d3.event object and scale and shift them:

SVGCanvas.prototype.mouseOffset = function() {
  var m = d3.event;
  m.x = (-this.transform.x + m.x) / this.transform.k;
  m.y = (-this.transform.y + m.y) / this.transform.k;
  return m;
}

Here, this.transform is an SVGCanvas property (discussed below). The x, y, and k properties map to transform="translate(x, y) scale(k)" from the <g> element. I’m keeping this code around because it may come in handy later.

We can do this coordinate transform automatically by making use of the d3.mouse method:

SVGCanvas.prototype.mouseOffset = function() {
  return d3.mouse(this.zoomG.node());
}

Here, an array (rather than object) is returned with entries [x, y]. The coordinates are automatically transformed relative to the specified element: the zoom group <g> (this.zoomG) element. Notice that I have called this.zoomG.node(). The .node() method returns the HTML block corresponding to the zoom group element. I still need to dig deeper into D3 to understand the reasoning behind why it is sometimes preferable to pass the node rather than the D3 object itself.

Zoom and Pan Methods

We’ve seen that we can manipulate the canvas by manipulating the transform attribute. Now we’re going to add methods and behaviors so that these manipulations will occur in response to user gestures.

As with the SVGCanvas.addRect methods from part 1, I’m going to specify zoom and pan methods in a sort-of hacky way. We’ll add a new property–SVGCanvas.zoomPan–that will have methods for zooming and panning the canvas. Then we’ll add new behaviors to the canvas that call these methods.

Zooming

Let’s start out by defining what should happen on zoom. Below I’ve set up the basic structure of the SVGCanvas method that defines both the zooming and panning function. Notice that we again need to be careful about setting the context. We set self = this, where this is the SVGCanvas. The zoom function proper begins on line 6. In principle, this could be a one-liner. Among other things, a zoom event will return information about the shifting and scaling that results from the event. I’m still a bit murky on how this works. That is, it will return an x, y, and k that we can use to specify the zoom group’s transform attribute a la translate(x, y) scale(k). We can access this information via d3.event.transform. We’ll store the information in the property self.transform for later.

 1 SVGCanvas.prototype.makeZoomPan = function() {
 2   // Defines zooming and panning behavior from zoom listener
 3 
 4   var self = this;
 5 
 6   zoom = function() {
 7     // What should happen when the wheel is scrolled?
 8     // Register transformation from event
 9     self.transform = d3.event.transform;
10     // Modify `transform` property of zoom-group
11     self.zoomG.attr('transform', self.transform);
12 
13     // Go back to initial position if zoomed out.
14     if (d3.event.transform.k === 1) {
15       self.zoomG
16         .transition(d3.transition() // Make movement less jerky
17           .duration(100)              
18           .ease(d3.easeLinear))
19         .attr('transform', 'translate(0,0) scale(1)');
20 
21       self.transform.x = 0;
22       self.transform.y = 0;
23       self.transform.k = 1;
24     }
25   }
26 
27   var pan = function() {
28     //...
29   }
30 
31   self.zoomPan = {zoom: zoom, pan: pan};
32 
33 }

The next step is just to modify the transform attribute of the zoom group (line 11). In version 4 of D3 we can simply pass d3.event.transform as the argument and be done with it. How does that work? Here’s my impression: The second argument of self.zoomG.attr(...) requires a string. When we pass an object the parser will look to see if the object has a .toString method. So, for example, if h = 5 then calling h.toString() will return the character “5”. The d3.event.transform.toString() method returns "translate(" + this.x + "," + this.y + ") scale(" + this.k + ")". So, in short, if d3.event.x = 5, d3.event.y = 6, and d3.event.k = 2 we get translate(5,6) scale(2). So the one-liner version of this is self.zoomG.attr('transform', d3.event.transform).

Now let’s turn to the part beginning in line 14. The idea behind this block is to reset the canvas positioning whenever we’re zoomed out completely. In short, it tests whether the scale is 1 (the minimum scaling level from .scaleExtent([1, 10])). If it is, we change the transform property of the zoom group element so that the translation is (0,0) (i.e. no translation). In lines 16-18 we use D3 transitions to make the canvas slide into the new position quickly (in 100ms) but gracefully. We then manually reset the properties of self.transform to the specified coordinates. In an earlier version of this code I tried resetting self.transform by calling d3.zoomTransform(this.zoomG.node()). This produced some odd behavior that I won’t get into. It now occurs to me that this probably doesn’t work because d3.zoomTransform(this.zoomG.node()) was called at some point in the 100ms during which the smooth transition was occurring. Thus, the new self.transform reflected the transform at some point along the transformation point rather than at x=0, y=0, k=1.

Panning

The pan method will be triggered by a drag event. As with the zoom method the basic idea will be to modify the transform attribute of the zoom group element (line 9).

What should the new transform be? Among other things, the d3.event object that is generated by a drag event has properties–.dx and .dy–related to how much the mouse has moved in each direction since the last time the event was triggered. We know the initial transform of the zoom group is from the self.transform property. We can then update this property using d3.event.dx and d3.event.dy.

 1 SVGCanvas.prototype.makeZoomPan = function() {
 2   // ...
 3   
 4   var pan = function() {
 5     self.transform.x += d3.event.dx;
 6     self.transform.y += d3.event.dy;
 7 
 8     // Update Attribute
 9     d3.select('g.zoom-group').attr('transform', self.transform);
10   }
11 
12   // ...
13 
14 }

Concluding Remarks

That’s it! To summarize, we’ve added panning and zooming to our still-rudimentary drawing app. The main ideas that I’ve introduced to do this were:

  1. Routing methods to handle complex events (e.g. shift + drag).
  2. Selective “silencing” of event handlers associated with D3 events (e.g. zoom but don’t pan).
  3. Use of the transform attribute of svg elements.

In the next part we’ll make it possible to moving and resize rectangles that we’ve already created.