semantic zooming of the force directed graph in d3

I tried to find a good tutorial to link to, but couldn’t find anything that really covered all the issues, so I’m going to write it out step-by-step myself.

First, you need to clearly understand what you’re trying to accomplish. This is different for the two types of zooming. I don’t really like the terminology Mike Bostock has introduced, (it’s not entirely consistent with non-d3 uses of the terms) but we might as well stick with it to be consistent with other d3 examples.

In “geometric zooming” you are zooming the entire image. Circles and lines get bigger as well as farther apart. SVG has an easy way to accomplish this through the “transform” attribute. When you set transform="scale(2)" on an SVG element, it is drawn as if everything was twice as big. For a circle, it’s radius gets drawn twice a big, and it’s cx and cy positions get plotted twice the distance from the (0,0) point. The entire coordinate system changes, so one unit is now equal to two pixels on screen, not one.

Likewise, transform="translate(-50,100)" changes the entire coordinate system, so that the (0,0) point of the coordinate system gets moved 50 units to the left and 100 units down from the top-left corner (which is the default origin point).

If you both translate and scale an SVG element, the order is important. If translate is before scale, than the translation is in the original units. If translate is after scale, than the translation is in the scaled units.

The d3.zoom.behavior() method creates a function that listens for mouse wheel and drag events, as well as for touch screen events associated with zooming. It converts these user events into a custom “zoom” event.

The zoom event is given a scale factor (a single number) and a translate factor (an array of two numbers), which the behaviour object calculates from the user’s movements. What you do with these numbers is up to you; they don’t change anything directly. (With the exception of when you attach a scale to the zoom behaviour function, as described later.)

For geometric zooming, what you usually do is set a scale and translate transform attribute on a <g> element that contains the content you want to zoom. This example implements that geometric zooming method on a simple SVG consisting of evenly placed gridlines:
http://jsfiddle.net/LYuta/2/

The zoom code is simply:

function zoom() {
    console.log("zoom", d3.event.translate, d3.event.scale);
    vis.attr("transform", 
             "translate(" + d3.event.translate + ")" 
                + " scale(" + d3.event.scale + ")"
             );
}

The zoom is accomplished by setting the transform attribute on “vis”, which is a d3 selection containing a <g> element which itself contains all the content we want to zoom. The translate and scale factors come directly from the zoom event that the d3 behaviour created.

The result is that everything gets bigger or smaller — the width of the gridlines as well as the spacing between them. The lines still have stroke-width:1.5; but the definition of what 1.5 equals on the screen has changed for them and anything else within the transformed <g> element.

For every zoom event, the translate and scale factors are also logged to the console. Looking at that, you’ll notice that if you’re zoomed out the scale will be between 0 and 1; if you’re zoomed in it will be greater than 1. If you pan (drag to move) the graph, the scale won’t change at all. The translate numbers, however, change on both pan and zoom. That’s because the translate represents the position of the (0,0) point in the graph relative to the position of the top-left-corner of the SVG. When you zoom, the distance between (0,0) and any other point on the graph changes. So in order to keep the content under the mouse or finger-touch in the same position on the screen, the position of the (0,0) point has to move.

There are a number of other things you should pay attention to in that example:

  • I’ve modified the zoom behaviour object with the .scaleExtent([min,max]) method. This sets a limit on the scale values that the behaviour will use in the zoom event, no matter how much the user spins the wheel.

  • The transform is on a <g> element, not the <svg> itself. That’s because the SVG element as a whole is treated as an HTML element, and has a different transform syntax and properties.

  • The zoom behaviour is attached to a different <g> element, that contains the main <g> and a background rectangle. The background rectangle is there so that mouse and touch events can be observed even if the mouse or touch isn’t right on a line. The <g> element itself doesn’t have any height or width and so can’t respond to user events directly, it only receives events from its children. I’ve left the rectangle black so you can tell where it is, but you can set it’s style to fill:none; so long as you also set it to pointer-events:all;. The rectangle can’t be inside the <g> that gets transformed, because then the area that responds to zoom events would also shrink when you zoom out, and possibly go out of sight off the edge of the SVG.

  • You could skip the rectangle and second <g> element by attaching the zoom behaviour directly to the SVG object, as in this version of the fiddle. However, you often don’t want events on the entire SVG area to trigger the zoom, so it is good to know how and why to use the background rectangle option.

Here’s the same geometric zooming method, applied to a simplified version of your force layout:
http://jsfiddle.net/cSn6w/5/

I’ve reduced the number of nodes and links, and taken away the node-drag behaviour and the node-expand/collapse behaviour, so you can focus on the zoom. I’ve also changed the “friction” parameter so that it takes longer for the graph to stop moving; zoom it while it’s still moving, and you’ll see that everything will keep moving as before .

“Geometric zooming” of the image is fairly straightforward, it can be implemented with very little code, and it results in fast, smooth changes by the browser. However, often the reason you want to zoom in on a graph is because the datapoints are too close together and overlapping. In that case, just making everything bigger doesn’t help. You want to stretch the elements out over a larger space while keeping the individual points the same size. That’s where “semantic zooming” comes into place.

“Semantic zooming” of a graph, in the sense that Mike Bostock uses the term, is to zoom the layout of the graph without zooming on individual elements. (Note, there are other interpretations of “semantic zooming” for other contexts.)

This is done by changing the way the position of elements is calculated, as well as the length of any lines or paths that connect objects, without changing the underlying coordinate system that defines how big a pixel is for the purpose of setting line width or the size of shapes or text.

You can do these calculations yourself, using the translate and scale values to position the objects based on these formulas:

zoomedPositionX = d3.event.translate[0] + d3.event.scale * dataPositionX 

zoomedPositionY = d3.event.translate[1] + d3.event.scale * dataPositionY

I’ve used that approach to implement semantic zooming in this version of the gridlines example:
http://jsfiddle.net/LYuta/4/

For the vertical lines, they were originally positioned like this

vLines.attr("x1", function(d){return d;})
    .attr("y1", 0)
    .attr("x2", function(d){return d;})
    .attr("y2", h);

In the zoom function, that gets changed to

vLines.attr("x1", function(d){
        return d3.event.translate[0] + d*d3.event.scale;
    })
    .attr("y1", d3.event.translate[1])
    .attr("x2", function(d){
        return d3.event.translate[0] + d*d3.event.scale;
    })
    .attr("y2", d3.event.translate[1] + h*d3.event.scale);

The horizontal lines are changed similarly. The result? The position and length of the lines changes on the zoom, without the lines getting thicker or thinner.

It gets a little complicated when we try to do the same for the force layout. That’s because the objects in the force layout graph are also being re-positioned after every “tick” event. In order to keep them positioned in the correct places for the zoom, the tick-positioning method is going to have to use the zoomed-position formulas. Which means that:

  1. The scale and translation have to be saved in a variable that can be accessed by the tick function; and,
  2. There needs to be default scale and translation values for the tick function to use if the user hasn’t zoomed anything yet.

The default scale will be 1, and the default translation will be [0,0], representing normal scale and no translation.

Here’s what it looks like with semantic zooming on the simplified force layout:
http://jsfiddle.net/cSn6w/6/

The zoom function is now

function zoom() {
    console.log("zoom", d3.event.translate, d3.event.scale);
    scaleFactor = d3.event.scale;
    translation = d3.event.translate;
    tick(); //update positions
}

It sets the scaleFactor and translation variables, then calls the tick function. The tick function does all the positioning: at initialization, after force-layout tick events, and after zoom events. It looks like

function tick() {
    linkLines.attr("x1", function (d) {
            return translation[0] + scaleFactor*d.source.x;
        })
        .attr("y1", function (d) {
            return translation[1] + scaleFactor*d.source.y;
        })
        .attr("x2", function (d) {
            return translation[0] + scaleFactor*d.target.x;
        })
        .attr("y2", function (d) {
            return translation[1] + scaleFactor*d.target.y;
        });

    nodeCircles.attr("cx", function (d) {
            return translation[0] + scaleFactor*d.x;
        })
        .attr("cy", function (d) {
            return translation[1] + scaleFactor*d.y;
        });
}

Every position value for the circles and the links is adjusted by the translation and the scale factor. If this makes sense to you, this should be sufficient for your project and you don’t need to use scales. Just make sure that you always use this formula to convert between the data coordinates (d.x and d.y) and the display coordinates (cx, cy, x1, x2, etc.) used to position the objects.

Where this gets complicated is if you need to do the reverse conversion from display coordinates to data coordinates. You need to do this if you want the user to be able to drag individual nodes — you need to set the data coordinate based on the screen position of the dragged node. (Note that this wasn’t working properly in either of your examples).

For geometric zoom, converting between screen position and data position can be down with d3.mouse(). Using d3.mouse(SVGElement) calculates the position of the mouse in the coordinate system used by that SVGElement. So if we pass in the element representing the transformed visualization, it returns coordinates that can be used directly to set the position of the objects.

The draggable geometric-zoom force-layout looks like this:
http://jsfiddle.net/cSn6w/7/

The drag function is:

function dragged(d){
    if (d.fixed) return; //root is fixed

    //get mouse coordinates relative to the visualization
    //coordinate system:    
    var mouse = d3.mouse(vis.node());
    d.x = mouse[0]; 
    d.y = mouse[1];
    tick();//re-position this node and any links
}

For semantic zoom, however, the SVG coordinates returned by d3.mouse() no longer directly correspond to the data coordinates. You have to factor in the scale and translation. You do this by re-arranging the formulas given above:

zoomedPositionX = d3.event.translate[0] + d3.event.scale * dataPositionX 

zoomedPositionY = d3.event.translate[1] + d3.event.scale * dataPositionY

becomes

dataPositionX = (zoomedPositionX - d3.event.translate[0]) / d3.event.scale

dataPositionY = (zoomedPositionY - d3.event.translate[1]) / d3.event.scale

The drag function for the semantic zoom example is therefore

function dragged(d){
    if (d.fixed) return; //root is fixed

    //get mouse coordinates relative to the visualization
    //coordinate system:
    var mouse = d3.mouse(vis.node());
    d.x = (mouse[0] - translation[0])/scaleFactor; 
    d.y = (mouse[1] - translation[1])/scaleFactor; 
    tick();//re-position this node and any links
}

This draggable semantic-zoom force-layout is implemented here:
http://jsfiddle.net/cSn6w/8/

That should be enough to get you back on track. I’ll come back later and add an explanation of scales and how they make all these calculations easier.

…and I’m back:

Looking at all the data-to-display conversion functions above, doesn’t it make you think “wouldn’t it be easier to have a function to do this each time?” That’s what the the d3 scales are for: to convert data values to display values.

You don’t often see scales in the force-layout examples because the force layout object allows you to set a width and height directly, and then creates d.x and d.y data values within that range. Set the layout width and height to your visualization width and height, and you can use the data values directly for positioning objects in the display.

However, when you zoom in on the graph, you switch from having the entire extent of the data visible to only having a portion visible. So the data values no longer directly correspond to positioning values, and we need to convert between them. And a scale function would make that a lot easier.

In D3 terminology, the expected data values are the domain and the desired output/display values are the range. The initial domain of the scale will therefore by the expected maximum and minimum values from the layout, while the initial range will be the maximum and minimum coordinates on the visualization.

When you zoom, the relationship between domain and range changes, so one of those values will have to change on the scale. Luckily, we don’t have to figure out the formulas ourselves, because the D3 zoom behaviour calculates it for us — if we attach the scale objects to the zoom behaviour object using its .x() and .y() methods.

As a result, if we change the drawing methods to use the scales, then all we have to do in the zoom method is call the drawing function.

Here’s the semantic zoom of the grid example implemented using scales:
http://jsfiddle.net/LYuta/5/

Key code:

/*** Configure zoom behaviour ***/
var zoomer = d3.behavior.zoom()
                .scaleExtent([0.1,10])
        //allow 10 times zoom in or out
                .on("zoom", zoom)
        //define the event handler function
                .x(xScale)
                .y(yScale);
        //attach the scales so their domains
        //will be updated automatically

function zoom() {
    console.log("zoom", d3.event.translate, d3.event.scale);

    //the zoom behaviour has already changed
    //the domain of the x and y scales
    //so we just have to redraw using them
    drawLines();
}
function drawLines() {
    //put positioning in a separate function
    //that can be called at initialization as well
    vLines.attr("x1", function(d){
            return xScale(d);
        })
        .attr("y1", yScale(0) )
        .attr("x2", function(d){
            return xScale(d);
        })
        /* etc. */

The d3 zoom behaviour object modifies the scales by changing their domain. You could get a similar effect by changing the scale range, since the important part is changing the relationship between domain and range. However, the range has another important meaning: representing the maximum and minimum values used in the display. By only changing the domain side of the scale with the zoom behaviour, the range still represents the valid display values. Which allows us to implement a different type of zoom, for when the user re-sizes the display. By letting the SVG change size according to the window size, and then setting the range of the scale based on the SVG size, the graph can be responsive to different window/device sizes.

Here’s the semantic zoom grid example, made responsive with scales:
http://jsfiddle.net/LYuta/9/

I’ve given the SVG percentage-based height and width properties in CSS, which will over-ride the attribute height and width values. In the script, I’ve moved all the lines which relate to the display height and width into a function that checks the actual svg element for it’s current height and width. Finally, I’ve added a window resize listener to call this method (which also triggers a re-draw).

Key code:

/* Set the display size based on the SVG size and re-draw */
function setSize() {
    var svgStyles = window.getComputedStyle(svg.node());
    var svgW = parseInt(svgStyles["width"]);
    var svgH = parseInt(svgStyles["height"]);

    //Set the output range of the scales
    xScale.range([0, svgW]);
    yScale.range([0, svgH]);

    //re-attach the scales to the zoom behaviour
    zoomer.x(xScale)
          .y(yScale);

    //resize the background
    rect.attr("width", svgW)
            .attr("height", svgH);

    //console.log(xScale.range(), yScale.range());
    drawLines();
}

//adapt size to window changes:
window.addEventListener("resize", setSize, false)

setSize(); //initialize width and height

The same ideas — using scales to layout the graph, with a changing domain from the zoom and a changing range from window resize events — can of course be applied to the force-layout. However, we still have to deal with the complication discussed above: how to reverse the conversion from data values to display values when dealing with node-drag events. The d3 linear scale has a convenient method for that, too: scale.invert(). If w = scale(x) then x = scale.invert(w).

In the node-drag event, the code using scales is therefore:

function dragged(d){
    if (d.fixed) return; //root is fixed

    //get mouse coordinates relative to the visualization
    //coordinate system:
    var mouse = d3.mouse(vis.node());
    d.x = xScale.invert(mouse[0]); 
    d.y = yScale.invert(mouse[1]); 
    tick();//re-position this node and any links
}

The rest of the semantic zoom force-layout example, made responsive with scales is here:
http://jsfiddle.net/cSn6w/10/


I’m sure that was a lot longer a discussion than you were expecting, but I hope it helps you understand not only what you need to do, but also why you need to do it. I get really frustrated when I see code that has obviously been cut-and-pasted together from multiple examples by someone who doesn’t actually understand what the code does. If you understand the code, it’s a lot easier to adapt it to your needs. And hopefully, this will serve as a good reference for other people trying to figure out how to do similar tasks.

Leave a Comment