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:
- The scale and translation have to be saved in a variable that can be accessed by the tick function; and,
- 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</e