How to combine drag and zoom in d3 version 4

Combining drag and zoom should be an easy task, and it is. But only if you do it right.

In this article I’ll present two ways to apply drag and zoom to your d3 visualisations. One way is easy and simple, and one way is complicated and difficult, and yet both examples hold key insights.

The easy way Link to heading

Mike Bostock has written a simple example where he combines drag and zoom. Click on the above picture to see his code.

Mike’s code is fairly self explanatory, so I’ll paraphrase from it. Roughly speaking, it goes like this:

Create a SVG element to house the visualisation. Create some points and have them in a phyllotaxis pattern.

var svg = d3.select("svg"),
    width = +svg.attr("width"),
    height = +svg.attr("height");

var points = d3.range(2000).map(phyllotaxis(10));

function phyllotaxis(radius) {
  var theta = Math.PI * (3 - Math.sqrt(5));
  return function(i) {
    var r = radius * Math.sqrt(i), a = theta * i;
    return {
      x: width / 2 + r * Math.cos(a),
      y: height / 2 + r * Math.sin(a)
    };
  };
}

Append a g element to the SVG element. This g element will contain all the circles.

var g = svg.append("g");

Add circles to the g element defined above. Allow these circles to be dragged by defining a drag handler in the selection.call function. Also define a function called dragged, which contains the code that runs whenever the drag event is detected.

g.selectAll("circle")
    .data(points)
  .enter().append("circle")
    .attr("cx", function(d) { return d.x; })
    .attr("cy", function(d) { return d.y; })
    .attr("r", 2.5)
    .call(d3.drag()
        .on("drag", dragged));

function dragged(d) {
  d3.select(this).attr("cx", d.x = d3.event.x).attr("cy", d.y = d3.event.y);
}

Add a zoom handler to the SVG element and define a maximum level that we can zoom in.  Also define a function called zoomed that triggers a transform whenever a zoom event is detected. The transform is applied to the g element defined in Step 2. This is important.

svg.call(d3.zoom()
    .scaleExtent([1 / 2, 8])
    .on("zoom", zoomed));

function zoomed() {
  g.attr("transform", d3.event.transform);
}

And we’re done. Quick and painless.

When we zoom, we apply a transform on the g element that contains the circles. Every circle inherits this transform from its parent element.

This means that when we drag a circle, the mouse event takes into account the transformation of the SVG element.

We don’t run into problems when we drag circles around. Life is good.

Now the second way.

The hard way Link to heading

This code follows much of the same logic as above.

We create a SVG element and append a g element to it. We create a bunch of circles at random that are appended to the g element. We even create a stylish black background that we also append to the SVG element.

var svg = d3.select("svg"),
    width = +svg.attr("width"),
    height = +svg.attr("height");
      
//create some circles at random points on the screen 
//create 50 circles of radius 20
//specify centre points randomly through the map function 
var radius = 20;
var circle_data = d3.range(50).map(function() {
    return{
        x : Math.round(Math.random() * (width - radius*2 ) + radius),
        y : Math.round(Math.random() * (height - radius*2 ) + radius)
    }; 
}); 

//stylish black rectangle for sexy looks 
var rect = svg.append("g")
    .attr("class", "rect")
    .append("rect")
    .attr("width", width)
    .attr("height", height)
    .style("fill", "black")
    ;
    
//funky yellow circles   
var circles = d3.select("svg")
	.append("g")
	.attr("class", "circles")
	.selectAll("circle")
        .data(circle_data)
        .enter()
        .append("circle")
        .attr("cx", function(d) {return(d.x)})
        .attr("cy", function(d) {return(d.y)})
        .attr("r", radius)
        .attr("fill", "yellow");       

Then we create a zoom handler and apply it to the SVG element.

//create zoom handler 
var zoom_handler = d3.zoom()
    .on("zoom", zoom_actions);
    
//specify what to do when zoom event listener is triggered 
function zoom_actions(){
  circles.attr("transform", d3.event.transform);
}

//add zoom behaviour to the svg element backing our graph.  
//same thing as svg.call(zoom_handler); 
zoom_handler(svg);

Now things start to go wrong.

Instead of defining the zoom transform on the g element containing the circles, like we did in the above example, we define the zoom transform on the circles themselves.

Every circle has the same transform applied to it. The g element has no transform applied onto itself.

A subtle difference, but a crucial one. Because now mouse events won’t do what we think they will, since they reference the original “transform” of the encompassing SVG element.

This means that when we add drag capabilities into the mix, things go haywire.

Try zooming in or out, and then dragging the circles around. They won’t drag to the cursor!

Lucky there’s a workaround. We need to change our drag function to take into account the current transform.

First step is to find out where the circle that we’re dragging is first located. We can log this against the start event in the drag handler.

//used to capture drag position    
var start_x, start_y;  

//create drag handler with d3.drag()
var drag_handler = d3.drag()
    .on("start", drag_start)
    .on("drag", drag_drag);

function drag_start(){
    // get starting location of the drag 
    // used to offset the circle 
     start_x = +d3.event.x;
     start_y = +d3.event.y;
}

The drag function is a bit complicated. In the below code, we

  • check to see if we’ve zoomed in or out at all. If we haven’t, we won’t be able to read the transform attribute because it won’t exist.
    • If we haven’t zoomed in or out, it means that we’re operating at a scale factor of 1 - the default.
    • If we have zoomed in or out, we need to get the current scale factor. We can extract it from the transform attribute of whatever we’re dragging by using some string manipulation techniques.
  • adjust the location of the circle to follow the mouse pointer. Because d3.event.x and d3.event.y follow the original non-transformed coordinate system of the SVG background, it means that the circle will drag too far when we’re zoomed in, and not far enough when we’re zoomed out. We fix this by taking the starting point and then adjusting for the current scale factor.

function drag_drag(d) {
    //Get the current scale of the circle 
    //case where we haven't scaled the circle yet
    if (this.getAttribute("transform") === null)
    {
        current_scale = 1; 
    } 
    //case where we have transformed the circle 
    else {
        current_scale_string = this.getAttribute("transform").split(' ')[1];
        current_scale = +current_scale_string.substring(6,current_scale_string.length-1);
    }
      d3.select(this)
        .attr("cx", d.x = start_x + ((d3.event.x - start_x) / current_scale) )
        .attr("cy", d.y = start_y + ((d3.event.y - start_y) / current_scale));
}

Last step is to apply the drag handler to the circles and we’re done.

drag_handler(circles);

It works. But it’s complicated and ugly.

Takeaways Link to heading

The first takeaway is if you want to add zoom functionality to a whole visualisation, it looks like it’s important to apply the zoom handler on the encompassing elements wherever possible.

So if you have a g element holding lots of circles, apply the zoom behaviour to the g element, not the individual circles. If you don’t do this then the mouse events won’t adjust to the current transform.

The second takeaway is that if something seems hard and convoluted that should be easy, odds are that you’ve missed something. Take a step back and figure out what’s the root cause of the problem.

Thanks for reading!


This post is part of a series of articles on how zoom works in version 4 of d3. Hope you enjoyed it!