Skip to content

Instantly share code, notes, and snippets.

@catherinekerr
Last active February 28, 2019 07:15
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save catherinekerr/b3227f16cebc8dd8beee461a945fb323 to your computer and use it in GitHub Desktop.
Save catherinekerr/b3227f16cebc8dd8beee461a945fb323 to your computer and use it in GitHub Desktop.
Zoom, pan, click to center

Zoom, pan, click to center

  • Use the mouse wheel to zoom in/out
  • Use mouse drag to pan canvas
  • Click on circle to enlarge and center in box

With this example, I wanted to solve a three-fold problem:

  1. Scale SVG to fit into smaller window size.
  2. Use mouse drag and thumbwheel to pan and zoom the SVG.
  3. Use mouse-click to zoom into specific path and center it.

The first and second problems are easily solved using the viewBox attribute and d3.behavior.zoom(). The difficulty is calculating the transform.translate co-ordinates when solving problem 3. When you use the viewBox attribute to transform the SVG, the scale is automatically reset to 1. Therefore, subsequent transformations need to take that into consideration.

In this example, there are three circles as described in file circles.tsv. The SVG viewbox (outlined in red) has width 840 and height 480, so we need to fit the 3 circles into this container. The bounding box that surrounds the 3 circles has top-left co-ordinates (200,100), width 2000 and height 2000.
So we are trying to fit an SVG of width 2000 and height 2000 into a viewbox of width 840 and height 480. The SVG is ~2.4 times the width and ~4.2 times the height of the viewbox. Therefore, if we want to scale uniformly, we must scale the whole SVG to 1/4.2 (0.24). However, with the viewBox attribute, we don't need to worry about this. Simply set the viewBox attribute to the top-left co-ordinates, width and height of the circles' bounding box as follows:


  bbox = container.node().getBBox();  
  vx = bbox.x;  
  vy = bbox.y;  
  vw = bbox.width;  
  vh = bbox.height;  

  defaultView = "" + vx + " " + vy + " " + vw + " " + vh;  

  svg  
	.attr("viewBox", defaultView)  
	.attr("preserveAspectRatio", "xMidYMid meet")  
        .call(zoom);  

container is the SVG group containing the 3 circles
vx and vy are the top-left co-ordinates of the container (200,100)
vw is the width from the left of the blue circle to the right of the red circle
vh is the height from the top of the blue circle to the bottom of the yellow circle
preserveAspectRatio scales the SVG uniformly
xMidYMid meet centers the SVG horizontally and vertically in the viewBox
.call(zoom) transforms the co-ordinates and scale upon upon using the mousewheel or upon dragging with the mouse

So far so good, but I also wanted to zoom into a circle when the circle is clicked. To do this, we need to increase the scale and center the bounding box of the circle in the viewbox. Remember that the scale has been reset to 1 using the viewBox attribute, so if we want to double the circle's width and height, we use scale = 2. Now the tricky bit is working out the translate values for the x and y co-ordinates. We do this using the following function:


  function getTransform(node, xScale) {
    bbox = node.node().getBBox();
    var bx = bbox.x;
    var by = bbox.y;
    var bw = bbox.width;
    var bh = bbox.height;
    var tx = -bx*xScale + vx + vw/2 - bw*xScale/2;
    var ty = -by*xScale + vy + vh/2 - bh*xScale/2;
    return {translate: [tx, ty], scale: xScale}
  }

bx and by are the top-left co-ordinates of the node (in this case, the circle that was clicked)
bw (and bh) is the diameter of the circle
vx, vy, vw, vh are as above
tx is the translation of the x co-ordinate
ty is the translation of the y co-ordinate

Note the formulas for tx and ty. You can try different scenarios by changing the width, height and scale values or by changing the circle positions and sizes in circles.tsv.

x y r fill
600 500 400 blue
1900 800 300 red
1200 1850 250 yellow
<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
margin: 5px;
}
button {
position: absolute;
right: 30px;
top: 30px;
}
</style>
<button>Reset</button>
<body>
<script src="//d3js.org/d3.v3.min.js"></script>
<script>
var margin = {top: 0, right: 0, bottom: 0, left: 0},
width = 840, // canvas width
height = 480; // canvas height
var scale = 1.0;
var zoom = d3.behavior.zoom()
.scale(scale)
.scaleExtent([1, 5])
.on("zoom", zoomed);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.attr("style", "outline: medium solid red;")
.call(zoom);
var container = svg.append("g")
.attr("id", "container")
.attr("transform", "translate(0,0)scale(1,1)");
var bbox, viewBox, vx, vy, vw, vh, defaultView;
var clickScale = 2.0; // scale used when circle is clicked
d3.select("button").on("click", reset);
d3.tsv("circles.tsv", circletype, function(error, circles) {
circle = container.append("g")
.attr("id", "circles")
.selectAll("circle")
.data(circles)
.enter().append("circle")
.attr("r", function(d) { return d.r; })
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.style("fill", function(d) { return d.fill; })
.on("click", clicked);
bbox = container.node().getBBox();
vx = bbox.x; // container x co-ordinate
vy = bbox.y; // container y co-ordinate
vw = bbox.width; // container width
vh = bbox.height; // container height
defaultView = "" + vx + " " + vy + " " + vw + " " + vh;
svg
.attr("viewBox", defaultView)
.attr("preserveAspectRatio", "xMidYMid meet")
.call(zoom);
});
/*
getTransform(node, scale) finds the correct translation co-ordinates in order
to center the circle (node) into the Viewbox. This depends on the scale.
- bbox is the bounding box of the circle (node)
- vx is the top-left x-cordinate of the Viewbox
- vy is the top-left y-cordinate of the Viewbox
- tx is the calculated movement in the x co-ordinate
- ty is the calculated movement in the y co-ordinate
- xScale is the scale of the SVG with respect to the default Viewbox
*/
function getTransform(node, xScale) {
bbox = node.node().getBBox();
var bx = bbox.x;
var by = bbox.y;
var bw = bbox.width;
var bh = bbox.height;
var tx = -bx*xScale + vx + vw/2 - bw*xScale/2;
var ty = -by*xScale + vy + vh/2 - bh*xScale/2;
return {translate: [tx, ty], scale: xScale}
}
function clicked(d, i) {
if (d3.event.defaultPrevented) {
return; // panning, not clicking
}
node = d3.select(this);
var transform = getTransform(node, clickScale);
container.transition().duration(1000)
.attr("transform", "translate(" + transform.translate + ")scale(" + transform.scale + ")");
zoom.scale(transform.scale)
.translate(transform.translate);
scale = transform.scale;
}
function circletype(d) {
d.x = +d.x;
d.y = +d.y;
d.r = +d.r;
return d;
}
function zoomed() {
var translateX = d3.event.translate[0];
var translateY = d3.event.translate[1];
var xScale = d3.event.scale;
container.attr("transform", "translate(" + translateX + "," + translateY + ")scale(" + xScale + ")");
}
function reset() {
scale = 1.0;
container.attr("transform", "translate(0,0)scale(1,1)");
zoom.scale(scale)
.translate([0,0]);
}
d3.select(self.frameElement).attr("margin", 10);
</script>
@singleghost
Copy link

I'm not quite understand the algorithm used to calculate transform in getTransform function. Can you say more in detail?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment