Skip to content

Instantly share code, notes, and snippets.

@tommct
Last active April 13, 2016 19:22
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 tommct/5671250 to your computer and use it in GitHub Desktop.
Save tommct/5671250 to your computer and use it in GitHub Desktop.
D3 Bounded Zoom

This D3 example demonstrates using the zoom event and limits the bounds of the zooming to a specified domain. It is largely based on http://bl.ocks.org/jasondavies/3689931, but with bounds. Most of this bounding is done in the refresh function. You need to zoom in before you can pan or zoom out.

<!DOCTYPE html>
<meta charset="utf-8">
<title>Constrained Zoom by Rectangle</title>
<script src="http://d3js.org/d3.v2.min.js?2.10.1"></script>
<style>
body {
font-family: sans-serif;
}
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
svg {
font: 9pt sans-serif;
shape-rendering: crispEdges;
}
rect {
fill: #ddd;
}
rect.zoom {
stroke: steelblue;
fill: #bbb;
fill-opacity: 0.5;
}
.axis path, .axis line {
fill: none;
stroke: #fff;
}
</style>
<p><label for="zoom-rect"><input type="checkbox" id="zoom-rect"> zoom by rectangle</label>
<script>
var margin = {top: 0, right: 12, bottom: 20, left: 60},
width = 960 - margin.left - margin.right,
height = 430 - margin.top - margin.bottom;
var xmin = 0,
xmax = 500,
ymin = 0,
ymax = 1000;
var x = d3.scale.linear()
.domain([xmin+.5, xmax+.5])
.range([0.5, width+.5]);
var y = d3.scale.linear()
.domain([ymin+.5, ymax+.5])
.range([height+.5, 0.5]);
var xAxis = d3.svg.axis()
.scale(x)
.orient("bottom")
.tickSize(-height);
var yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.ticks(5)
.tickSize(-width); // tickLine == gridline
var refresh = function() {
var reset_s = 0;
if ((x.domain()[1] - x.domain()[0]) >= (xmax - xmin)) {
zoom.x(x.domain([xmin, xmax]));
reset_s = 1;
}
if ((y.domain()[1] - y.domain()[0]) >= (ymax - ymin)) {
zoom.y(y.domain([ymin, ymax]));
reset_s += 1;
}
if (reset_s == 2) { // Both axes are full resolution. Reset.
zoom.scale(1);
zoom.translate([0,0]);
}
else {
if (x.domain()[0] < xmin) {
x.domain([xmin, x.domain()[1] - x.domain()[0] + xmin]);
}
if (x.domain()[1] > xmax) {
var xdom0 = x.domain()[0] - x.domain()[1] + xmax;
x.domain([xdom0, xmax]);
}
if (y.domain()[0] < ymin) {
y.domain([ymin, y.domain()[1] - y.domain()[0] + ymin]);
}
if (y.domain()[1] > ymax) {
var ydom0 = y.domain()[0] - y.domain()[1] + ymax;
y.domain([ydom0, ymax]);
}
}
svg.select(".x.axis").call(xAxis);
svg.select(".y.axis").call(yAxis);
}
var zoom = d3.behavior.zoom().x(x).y(y).scaleExtent([.001, Infinity]).on("zoom", refresh);
var zoomRect = false;
d3.select("#zoom-rect").on("change", function() {
zoomRect = this.checked;
});
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(zoom)
.append("g")
.on("mousedown", function() {
if (!zoomRect) return;
var e = this,
origin = d3.mouse(e),
rect = svg.append("rect").attr("class", "zoom");
d3.select("body").classed("noselect", true);
origin[0] = Math.max(0, Math.min(width, origin[0]));
origin[1] = Math.max(0, Math.min(height, origin[1]));
d3.select(window)
.on("mousemove.zoomRect", function() {
var m = d3.mouse(e);
m[0] = Math.max(0, Math.min(width, m[0]));
m[1] = Math.max(0, Math.min(height, m[1]));
rect.attr("x", Math.min(origin[0], m[0]))
.attr("y", Math.min(origin[1], m[1]))
.attr("width", Math.abs(m[0] - origin[0]))
.attr("height", Math.abs(m[1] - origin[1]));
})
.on("mouseup.zoomRect", function() {
d3.select(window).on("mousemove.zoomRect", null).on("mouseup.zoomRect", null);
d3.select("body").classed("noselect", false);
var m = d3.mouse(e);
m[0] = Math.max(0, Math.min(width, m[0]));
m[1] = Math.max(0, Math.min(height, m[1]));
if (m[0] !== origin[0] && m[1] !== origin[1]) {
zoom.x(x.domain([origin[0], m[0]].map(x.invert).sort(function(a,b) { return a - b;})))
.y(y.domain([origin[1], m[1]].map(y.invert).sort(function(a,b) { return a - b;})));
}
rect.remove();
refresh();
}, true);
d3.event.stopPropagation();
});
svg.append("rect")
.attr("width", width)
.attr("height", height);
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
</script>
@pkerpedjiev
Copy link

Great example, but I think it's missing something. When somebody scrolls off to one side, the domain of x and y scales are changed, but the zoom's translate value doesn't change. This means that if you drag the plot to right (i.e. pan left), then drag right (pan left) some more, when you want to pan back right you have to undo all of the left panning you previously did before the plot actually moves.

To fix this, change the translate values of the zoom right after changing the domain at the edge cases. Here's a diff:

diff --git a/index.html b/index.html
index d0926ac..e103e7b 100644
--- a/index.html
+++ b/index.html
@@ -2,6 +2,7 @@
 <meta charset="utf-8">
 <title>Constrained Zoom by Rectangle</title>
 <script src="http://d3js.org/d3.v2.min.js?2.10.1"></script>
+
 <style>

 body {
@@ -58,6 +59,9 @@ var y = d3.scale.linear()
     .domain([ymin+.5, ymax+.5])
     .range([height+.5, 0.5]);

+var xOrigScale = x.copy();
+var yOrigScale = y.copy();
+
 var xAxis = d3.svg.axis()
     .scale(x)
     .orient("bottom")
@@ -86,17 +90,27 @@ var refresh = function() {
   else {
     if (x.domain()[0] < xmin) {
       x.domain([xmin, x.domain()[1] - x.domain()[0] + xmin]);
+
+        zoom.translate([xOrigScale.range()[0] - xOrigScale(x.domain()[0]) * zoom.scale(),
+                        zoom.translate()[1]])
     }
     if (x.domain()[1] > xmax) {
       var xdom0 = x.domain()[0] - x.domain()[1] + xmax;
       x.domain([xdom0, xmax]);
+
+      zoom.translate([xOrigScale.range()[0] - xOrigScale(x.domain()[0]) * zoom.scale(),
+                      zoom.translate()[1]])
     }
     if (y.domain()[0] < ymin) {
       y.domain([ymin, y.domain()[1] - y.domain()[0] + ymin]);
+
+      zoom.translate([zoom.translate()[0], yOrigScale.range()[0] - yOrigScale(y.domain()[0]) * zoom.scale()])
     }
     if (y.domain()[1] > ymax) {
       var ydom0 = y.domain()[0] - y.domain()[1] + ymax;
       y.domain([ydom0, ymax]);
+
+      zoom.translate([zoom.translate()[0], yOrigScale.range()[0] - yOrigScale(y.domain()[0]) * zoom.scale()])
     }
   }
   svg.select(".x.axis").call(xAxis);
@@ -164,4 +178,4 @@ svg.append("g")
 svg.append("g")
     .attr("class", "y axis")
     .call(yAxis);
-</script>
\ No newline at end of file
+</script>

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