Michael Wooley's Homepage

Drawing With D3.js Part 3: Moving and Resizing

We’re going build on the foundation we’ve created in posts 1 and 2 of this series by adding the ability to move, resize, and delete rectangles that we’ve already added to the canvas. The basic idea will be to add additional, invisible rectangles at the corners and edges of the rectangles. We’ll then attach behaviors to these bounding rectangles in order to resize, reshape, and delete.

Here’s what we’re going to create:

Try hovering over the edges of rectangles and ctrl + click gestures. See the full set of controls and code at the gist.

I’m going to tackle this in the following set of steps:

  1. Change the basic structure of the code by:
    • Adding the notion of a state, which will contain information about what sort of rectangles we’re adding.
    • Adding a notion of active rectangles.
  2. Append a full complement of rectangles to the corners and edges of each added rectangle. We will use these elements to detect events.
  3. Add the ability to delete several rectangles at once. This will draw on our notion of active rectangles, which I will discuss later.
  4. Attach resize and move behaviors to our elements.

Okay, that’s a lot to do in one post. I’m going to be a bit more sketchy than in previous posts and focus attention on what I consider to be the most important parts. Of course, if you’re reading this and really want to figure out what’s going on but can’t please feel free to get in touch!

Modifying the Old to Accommodate the New

I’m going to start out by discussing what parts of our old code we need to change so that we can work in the new code.

Defining a Canvas “state

We’re going to add a notion of a “state” to the canvas element. This element will come in handy when we start to make the canvas elements more elaborate and extract data from them. For now, though, it will just come in handy when we need to do things like name elements.

Within the SVGCanvas constructor let’s create a state like so:

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

  // Set the state (elaborate on more later)
  this.state = {
    type: 'Table',
    color: '#d32f2f',
    count: 0,
    class: 'rect-table',
    id: 'Table-0',
  }

  // more ...
}

As you can see, this is just an object that contains some information that looks sort of arbitrary. Indeed, for now, you should just think of all of this ‘Table’ talk as a arbitrary name that we’ll give our rectangle.

Alterations to addRect

We need to add and modify the methods that we use to add rectangles (contained within the SVGCanvas.addRect methods) to add resize, move, and delete functionality.

Using the state

For today, we’ll use the SVGCanvas.state property to define attributes of the added rectangles in the SVGCanvas.addRect methods. In particular, in SVGCanvas.addRect.start we’ll attach classes of state.id to both the rectangle and the group that it is contained within. We’ll also update the state.id and state.count in SVGCanvas.addRect.end:

 1 SVGCanvas.prototype.makeAddRect = function () {
 2   // Previous...
 3 
 4   start = function () {
 5     //Add a rectangle
 6     // 1. Get mouse location in SVG
 7     var m = self.mouseOffset();
 8     x0 = m[0];
 9     y0 = m[1];
10     // 2. Add a new group
11     self.Rect.g = self.zoomG
12       .append('g')
13       .attr('class', 'g-rect ' + self.state.id);
14     // 3. Make a rectangle
15     self.Rect.r = self.Rect.g
16       .append('rect') // An SVG `rect` element
17       .attr('x', x0) // Position at mouse location
18       .attr('y', y0)
19       .attr('width', 1) // Make it tiny
20       .attr('height', 1)
21       .attr('class', 'rect-main ' + self.state.class + ' ' + self.state.id)
22       .style('stroke', self.state.color)
23       .style('fill', 'none');
24     // 4. Make it active.
25     self.setActive(self.state.id);
26   }
27   drag = function () {...}
28   end = function () {
29     // What to do on mouseup
30     // Add Rectangle Transformation Methods
31     self.transformRect();
32     // Clear out rect.
33     self.Shapes[self.state.id] = self.Rect;
34     // Update count and id
35     self.state.count += 1;
36     self.state.id = self.state.type + '-' + self.state.count;
37   }
38 
39   // more...
40 }

Active Groups

In line 25 we called a new method, self.setActive, using the new rectangle’s id as the argument. We’ll discuss the idea of active groups in more detail below. One immediate effect of being an active group is that the rectangle’s color is brighter. In particular, we’re shifting the rectangle’s stroke-opacity attribute from 0.5 to 1. The CSS styling for this part is a bit more detailed than previously (but still relatively simple).

Adding Rectangle Transformation Methods

In line 31 we called a new method, self.transformRect. This is the method that we’re going to use to attach all of the resize and move behaviors below.

Rectangle Groups

We’re going to want our rectangles to react to gestures at the edges and corners. However, we can only explicitly attach behaviors to an entire <rect> element, not to edges and corners. To get around this we’re going to add several invisible rectangles at the corners and edges of each rectangle that is drawn. We’ll then attach behaviors to these elements. The downside of this strategy is that we now have to keep track of many more elements than previously. To deal with this, we’ll organize these elements into groups.

Here’s some sample html of what the rectangle group will look like:

<!--  The Group -->
<g class="g-rect Table-0 active">
  <!--  The visible rectangle -->
  <rect x="59" y="139" width="90" height="67" class="rect-main rect-table Table-0"></rect>
  
  <!-- Hidden Rectangles on each edge of visible rectangle -->
  <rect class="rectEdge cornerEdge Table-0 rectEdge-bottom" x="63" y="201" width="81" height="9"></rect>
  <rect class="rectEdge cornerEdge Table-0 rectEdge-top" x="63.0" y="134.0" width="81.0" height="9"></rect>
  <rect class="rectEdge cornerEdge Table-0 rectEdge-right" x="145.0" y="143.0" width="9" height="58"></rect>
  <rect class="rectEdge cornerEdge Table-0 rectEdge-left" x="54.0" y="143.0" width="9" height="58"></rect>
  
  <!-- Hidden Rectangles at each corner of visible rectangle -->
  <rect height="9" width="9" class="rectCorner cornerEdge nwse Table-0 rectCorner-botright" x="145.0" y="201.0"></rect>
  <rect height="9" width="9" class="rectCorner cornerEdge nwse Table-0 rectCorner-topleft" x="54.0" y="134.0"></rect>
  <rect height="9" width="9" class="rectCorner cornerEdge nesw Table-0 rectCorner-botleft" x="54.0" y="201.0"></rect>
  <rect height="9" width="9" class="rectCorner cornerEdge nesw Table-0 rectCorner-topright" x="145.0" y="134.0"></rect>
</g>

I have inserted a special “debug” flag into the code so that it is easy to see these “hidden” rectangles:

This demo features the same code as the original demo but now the bounding rectangles are visible.

Adding and Handling The Rectangle Group

We’re going to want all of these hidden rectangles to move along with the main rectangle. To do this we’re going to use a two-step approach. First, we’ll add some data to the group element containing information about the bounding box of the main (visible) rectangle. Then, we’ll define a function that sets the coordinates of each element in the group (i.e. visible, edge, and corner rectangles) as a function of this data. Any time we want to modify the rectangle, then, we’ll update the data and call the coordinate-setting function. You might be wondering why I didn’t just use the group transform attribute that I discussed in detail in part 2. The reason is that–ultimately–I’m going to want all of the rectangle groups to be on the same coordinate system so that I can extract data from them and compare rectangles. Thus, it makes sense to change the actual and not just apparent position of the rectangles.

Here is a taste of the SVGCanvas.transformRect method, which we call each time a new rectangle is added. You can see that we get the current rectangle and group; we then add the data (p = {...}) to the group element. The main sub-method finishes by calling another sub-method (makeRectEdgeCorner), which adds the hidden bounding rectangles.

SVGCanvas.prototype.transformRect = function () {
  var self = this;
  var groupClass, debug, g, r, p, dbWidth;

  var main = function () {

    r = self.Rect.r;
    g = self.Rect.g;
    dbWidth = self.options.rectOpt.dbWidth;
    // Set common class
    groupClass = self.state.id;
    debug = self.options.debug ? (' debug') : ('');

    // Add data to the group element
    var rBB = r.node().getBBox();
    p = {
      x: rBB.x,
      y: rBB.y,
      w: rBB.width,
      h: rBB.height,
      id: groupClass,
    };
    g = g.data([p]);

    // Add the hidden bounding rectangles
    makeRectEdgeCorner();
  }

  main();

  function setCoordsData(d) {
    // Set the coordinates of a rectangle-group

    var children = d3.selectAll('g.active.' + d.id);

    // Main Rectangle
    children.select('rect.rect-main')
      .attr('x', d.x)
      .attr('y', d.y)
      .attr('width', d.w)
      .attr('height', d.h);

    // rectEdge.left
    children.select('rect.rectEdge.rectEdge-left')
      .attr('x', d.x - (dbWidth / 2))
      .attr('y', d.y + (dbWidth / 2))
      .attr('width', dbWidth)
      .attr('height', Math.abs(d.h - dbWidth));

    ...
  }

  // Add move and resize methods
  function moveRect() {...}
  }

  function resizeRect() {
    // Resize the rectangle by dragging the corners

    function getDragCorners() {...}

    var makeContainer = function (id) {...}

    // Make drag containers for each 
    return {
      makeContainer: makeContainer,
    }
  }

  // Append helper rectEdges and rectCorners to g
  function makeRectEdgeCorner() {...}
}

Setting Coordinates

Notice the snippet of the function setCoordsData in the code block above. This is the method that will be called to update the coordinates (i.e. x, y, height, width) of rectangles given the group dataset d. It is a long function because we’re altering attributes in nine different elements (one visible, four edges, and four corners). In the end, though, the only part that requires a bit of thought is figuring out what the coordinates of the bounding rectangles ought to be in relation to the main rectangle.

The basic idea is to find the child elements of the group pertaining to each shape. With the main rectangle the coordinates can be updated straightforwardly. The bounding rectangle on the left edge has coordinates that are relatively-straightforward functions of the main rectangle’s coordinates.

Moving and Resizing Rectangles

We define methods for moving and resizing the rectangles within SVGCanvas.transformRect. As alluded to above, the basic strategy in both cases will be to 1.) register a mouse movement, 2.) update the group data elements (x, y, height, width), and 3.) calling the setCoordsData to actually move the elements.

A new element of this code is that we’re going to update the “active” status of each rectangle group when a move or resize occurs (just like when we add a new rectangle). I decided to make the rectangle movements something that occurs to all active rectangles (i.e. they move together). Rectangle resizes, however, only occur for the rectangle that the mouse is currently hovering over. This difference partly has to do with how the code is written at the moment. However, I also think that this behavior is more natural.

Both of these behaviors are appended to the relevant bounding rectangles within the makeRectEdgeCorner sub-method.

Moving

We can move the rectangle by attaching a dragging behavior to the edge bounding rectangles. As with the addRect method from part 1, we make a moveRect method by defining functions specifying what should occur in the beginning, middle, and end of the drag.

function moveRect() {
  // Move the rectangle by dragging edges
  var activeG;

  function start() {
    self.setActive(groupClass);
    self.svg.style('cursor', 'move');
    activeG = d3.selectAll('g.active');
  }

  function drag() {

    activeG.each(
        function (d, i) {
          // Alter Parameters
          d.x = Math.max(0, Math.min(self.options.w - d.w, d.x + d3.event.dx));
          d.y = Math.max(0, Math.min(self.options.h - d.h, d.y + d3.event.dy));

          // Set Coordinates
          setCoordsData(d);
        }
      )
  }

  function end() {
    // Undo formatting
    self.svg.style('cursor', 'default');
  }

  // What to do on drag
  var dragcontainer = d3.drag()
    .on('start', start)
    .on('drag', drag)
    .on('end', end);

  return {
    drag: dragcontainer,
  }
}

The start method simply adds the clicked rectangle group to the set of “active” groups. Concretely, this means that the <g> element now has the class “active”. We then collect all of the active group elements in a variable, activeG.

Some new techniques are introduced in the drag method. When a drag occurs we use the .each(...) method to carry out a function on each of the elements in activeG. The .each call gives us variables d and i, which pertain to the group data and element count (i.e. fifth out of six active elements).

We update the data with new x and y coordinates for the rectangle. The coordinate updates are written so that the element cannot exceed the boundaries of the <svg> element. Because the data object is mutable the x and y coordinates of the group data element are updated automatically. That is, we don’t need to have a line like, d3.select(this).data = d within the .each(...) call. For more on mutable objects see part 1 of this series.

We then call setCoordsData(d) to update the location of the rectangles in the group. The drag event ends by undoing some of the styling that we added at the beginning.

Resizing

The resizeRect sub-method is used to drag and resize the rectangle group. It was a whole lot of fun because the new rectangle coordinates depend on the corner on which the user clicks. Two examples of these delightful coordinate changes can be seen in getDragCorners, a helper function. They account for both 1.) whether the mouse has exited the svg and 2.) whether the rectangle has been dragged back on top of itself. For example, when the bottom-left corner is dragged so that it is the top-right corner

The main code in makeContainer follows the now-familiar pattern to specify a drag behavior.

function resizeRect() {
  // Resize the rectangle by dragging the corners

  function getDragCorners() {
    return {
      topleft: function (d, bb0, m) {
        d.x = Math.max(0, Math.min(bb0.x + bb0.width, m[0]));
        d.y = Math.max(0, Math.min(bb0.y + bb0.height, m[1]));
        d.w = (m[0] > 0) ? Math.min(Math.abs(self.options.w - d.x), Math.abs(bb0.x + bb0.width - m[0])) : d.w;
        d.h = (m[1] > 0) ? Math.min(Math.abs(self.options.h - d.y), Math.abs(bb0.y + bb0.height - m[1])) : d.h;
      },

      topright: function (d, bb0, m) {
        d.x = Math.max(0, Math.min(bb0.x, m[0]));
        d.y = Math.max(0, Math.min(bb0.y + bb0.height, m[1]));
        d.w = (m[0] > 0) ? Math.min(Math.abs(self.options.w - d.x), Math.abs(bb0.x - m[0])) : d.w;
        d.h = (m[1] > 0) ? Math.min(Math.abs(self.options.h - d.y), Math.abs(bb0.y + bb0.height - m[1])) : d.h;
      },

      botleft: function (d, bb0, m) {...},

      botright: function (d, bb0, m) {...}
    };
  }

  var makeContainer = function (id) {
    // Make a container, which depends on the corner (specified by `id`)
    var dragCorners, cursor, bb0;

    // Get the correct transformation function
    dragCorners = getDragCorners()[id];
    // Get the correct cursor
    if (contains(['topleft', 'botright'], id)) {
      cursor = 'nwse-resize';
    } else {
      cursor = 'nesw-resize';
    }

    var start = function () {
      // Set the present group to be active
      self.setActive(groupClass, false);
      // Get the active groups
      activeG = d3.selectAll('g.active');
      // Get the initial Bounding Box
      bb0 = r.node().getBBox();
      // Display correct cursor tip
      self.svg.style('cursor', cursor);
    }

    var drag = function () {
      // Mouse position
      m = d3.mouse(self.zoomG.node());
      // Update parameters depending on
      dragCorners(g.datum(), bb0, m);
      // Set the coordinates
      setCoordsData(g.datum());
    }

    // ....EXCLUDING CODE:
    //... end() and make container
  }
  //... Return Make drag containers for each 
}

The makeContainer method requires an argument, id, which specifies the corner for which the behavior will apply. With this info the method can get the correct entry in makeDragBehavior, which will determine how the group data is updated. The only other thing that is idiosyncratic across corners is the correct cursor styling.

In makeRectDrag we can then specify the behavior on a given corner (e.g. the topleft) by writing something along the lines of:

d3.select('rect.topleft')
  .call(resize.makeContainer('topleft'));

Odds and Ends

Handling Active Rectangle Groups

Here I’m going to discuss the method that handles this designation. When adding, moving, or resizing a rectangle we’ve called the setActive method. Let’s discuss exactly what that does.

Here is the code:

SVGCanvas.prototype.setActive = function (id, force_clear = false) {
  // Sets class to active for selected groups.
  var deactivate = false;

  // When should all other groups be deactivated?
  //  1.A If the ctrl key is not pressed
  //  1.B If the present element isn't already active
  //  (Use De Morgan's Rules for this one.)
  deactivate = deactivate || !(d3.event.sourceEvent.ctrlKey || d3.selectAll('g.' + id).classed('active'));
  //  2. If we didn't force it to be.
  deactivate = deactivate || force_clear;
  // If any of these conditions met, clear the active elements.
  if (deactivate) {
    this.svg.selectAll('g.active').classed('active', false);
  }

  // Add 'active' class to any 'g' element with id = id passed.
  d3.selectAll('g.' + id).classed('active', true);
}

There are two parts:

  1. Determine if the “active” designation should be removed from the groups that are currently “active”. Removal depends on:
    • Whether the ctrl key is pressed (don’t remove if it is).
    • If we’re clicking an already-active rectangle. And ctrl is not clicked. Again, this halts removal.
    • If we’ve…uh…forced a removal via the argument force_clear.
  2. Add the “active” designation to the <g> element that has class id. Given the way in which id is used in the current code, there will be a unique <g> element with this class.

“Delete” and Other Keydown Events

We created another new prototype method (SVGCanvas.keydownEventHandlers) that will serve to listen for keydown events. In the SVGCanvas constructor we attach this behavior with the line d3.select('body').on('keydown', this.keydownEventHandlers);. Notice that the behavior is attached to the body element of the html.

SVGCanvas.prototype.keydownEventHandlers = function () {
  // Event handler for keydown events

  // Press 'Delete' to remove all active groups.
  if (d3.event.key === 'Delete') {
    d3.selectAll('g.active').remove();
  }
}

A leading example of this sort of behavior involves deleting all “active” rectangle groups when the delete key is pressed. This can be done easily by checking if the pressed key is 'Delete' then removing all <g> elements with class active.

Modified Zoom-and-Pan Behavior

In part 2 of this series I toyed around with ways of ensuring that our “drawing” doesn’t venture too far off of the <svg> element. For example, if we zoom in, move the mouse, and zoom back out we would like to be viewing the same drawing.

I was never completely satisfied with the “solution” from part 2 (a sometimes-jerky transition back to square one). Now I think I’ve found a superior alternative.

In short, any time a zoom or pan event occurs, we’re going to call a function that checks whether the proposed d3.event.transform would transform us out of the <svg>. If it would, then the transform is modified so that we stick at the boundary. The principles underlying the code are quite similar to those used to check whether we were resizing or moving the rectangle out of bounds.

The new function is called checkBounds and it is contained within the SVGCanvas.makeZoomPan method. It basically consists in testing a lot of boundary conditions and modifying the transform accordingly.

An example of the new behavior is that it is impossible to pan when completely zoomed out; panning would only drag in the areas that are off the svg.

Conclusion

There are still a lot of drawing features that could be added. For example, it would be nice to have a way to undo/redo actions and select elements with a lasso-like tool. To implement undo/redo we could create a “history” of past actions. For each action (e.g. add, resize, move, delete) we could create a sub-method that undoes the action. At the end of each action, then, we’d append this “undo” sub-method to the undo history. We could also use the code that we’ve just created for rectangles as a basis for adding additional shapes.

At this point, however, we have the minimum drawing functionality that will be needed to carry out the application that I have in mind. In the next two posts I’m going to move to thinking of ways to add and extract data from the drawing. In particular, I’m going to be interested in specifying and viewing bounding boxes around elements of printed tables from (for example) the Census of Manufacturers.