interwebjill
interwebjill

Reputation: 950

reversing order of d3.zoom scale and translate

If you click the red button in this example:

https://bl.ocks.org/interwebjill/fe782e6f195b17f6fe6798a24c390d90

you can see that the chart translates so that the circle is in the center and then zooms in to a specified level (reclicking on the button zooms back out). Translating and then zooming in this way leaves a gap on the left that I would rather not have. How might I change the code so that the chart zooms first and then translates to center so that I don't have this gap in the chart?

I have tried reversing the order of the scale and translate in both the zoom definition and the zoomToExtent function but there is no different in effect.

Upvotes: 3

Views: 1762

Answers (1)

Andrew Reid
Andrew Reid

Reputation: 38211

The ultimate source of the problem is d3.interpolateZoom. This interpolator has scale interpolate faster than translate - even though they mostly both are transitioning at the same time. The pattern implemented with d3.interpolateZoom is based on this paper.

Because scale and translate both interpolate differently in d3.interpolateZoom, you get a gap in the side of your chart as the scale decreases/increases more rapidly than the translate values.

d3.interpolateZoom is used when you call the zoom on a transition.

However, if you apply a transform directly on a transition using .attr(), the d3 transition will use d3.interpolateString, which will search the start and end strings for corresponding numbers and use d3.interpolateNumber on those. This will apply the same interpolation to both scale and translate.

Using both methods we can compare the discrepancy between d3.interpolateZoom and d3.interpolateString. Below the black rectangle uses d3.interpolateString while the orange rectangle uses d3.interpolateZoom. Click on a rectangle to start the transition:

var svg = d3.select("body").append("svg")
   .attr("width", 500)
   .attr("height", 300);
   
var g1 = svg.append("g"), g2 = svg.append("g");

var zoom1 = d3.zoom().on("zoom", function() { 
   g1.attr("transform", d3.event.transform);
});

var zoom2 = d3.zoom().on("zoom", function() {
   g2.attr("transform", d3.event.transform);
});

g1.call(zoom1.transform, d3.zoomIdentity  
       .translate(150, 100)
       .scale(2));
       
g2.call(zoom2.transform, d3.zoomIdentity
       .translate(150,100)
       .scale(2));

g1.append("rect")
   .attr("x", 20)
   .attr("y", 20)
   .attr("width", 50)
   .attr("height", 50);
   
g2.append("rect")
  .attr("x", 22)
  .attr("y", 22)
  .attr("width", 46)
  .attr("height",46)
  .attr("fill","orange");
   
d3.selectAll("rect").on("click", function() {
                                                               
   g1.transition()
      .duration(6000)
      .attr("transform", d3.zoomIdentity)
      .on("end", function() {
				d3.select(this).call(zoom1.transform, d3.zoomIdentity);			  
			})
      
   g2.transition()
      .duration(6000)
      .call(zoom2.transform, d3.zoomIdentity)
      

});
<script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>

Where the first rectangle transitions the transform with .attr(), we need to call the zoom afterwards to ensure the zoom has the current transform, we don't need to in this example, but if you wanted to use the zoom after the transform you need to do this

Comparing these two we get:

enter image description here enter image description here

(Y axis indicates percentage remaining in transition from start attribute to end attribute)


You want scale and translate to move simultaneously at the same rate when transitioning. We can do this if we use a tweening function. Unlike above we can't just use transition().attr("transform",newTransfrom) because you are also drawing canvas and updating the axis. So we'll need to create our own tweening function that can use the current transform and scale, apply it to the axis, canvas, and markers.

For example, rather than calling the zoom (which will use d3.interpolateZoom):

function zoomToExtent(d0, d1) {
  zoomRect.call(zoom).transition()
    .duration(1500)
    .call(zoom.transform, d3.zoomIdentity  
       .translate(-xSVG(d0), 0)
       .scale(width / (xSVG(d1) - xSVG(d0))));
  }

Instead, we can use a tweening function which controls the element's transform and applies the same interpolator to scale and translate:

function zoomToExtent(d0, d1) {
  //get transition start and end values:
  var startScale = d3.zoomTransform(zoomRect.node()).k;
  var startTranslate = d3.zoomTransform(zoomRect.node()).x;
  var endTranslate = -xSVG(d0);
  var endScale = width / (xSVG(d1) - xSVG(d0));

  zoomRect.call(zoom).transition()
    .duration(1500)
    .tween("transform", function() {
      var interpolateScale = d3.interpolateNumber(startScale,endScale);
      var interpolateTranslate = d3.interpolateNumber(startTranslate,endTranslate);

      return function(t) { 
          var t = d3.zoomIdentity.translate(interpolateTranslate(t),0).scale(interpolateScale(t));
          zoomed(t);
        }
      })
      .on("end", function() {    // update the zoom identity on end:
        d3.select(this).call(zoom.transform, d3.zoomIdentity  
       .translate(endTranslate, 0)
       .scale(endScale));
      })

  }

You may notice I'm passing a transform value to the zoomed function, since there is no d3.event.transform for this, we need to modify the zoomed function to use the passed parameter if available, otherwise to fall back on the event transform:

function zoomed(transform) {
   var t = transform || d3.event.transform;
   ...

Altogether, that might look something like this.


For another comparison between the two transitioning methods, I've created a gridded comparison that can be toggled between the two zoom identities:

var svg = d3.select("body").append("svg")
   .attr("width", 510)
   .attr("height", 310);
   
var g1 = svg.append("g");
var g2 = svg.append("g");
   
var rectangles1 = g1.selectAll()
  .data(d3.range(750))
  .enter()
  .append("rect")
  .attr("x", function(d) { return d%25*20; })
  .attr("y", function(d) { return Math.floor(d/25)*20; })
  .attr("width", 20)
  .attr("height", 20)
  .attr("fill","#ccc")
  .attr("stroke","white")
  .attr("stroke-width", 2);
  
var rectangles2 = g2.selectAll()
  .data(d3.range(750))
  .enter()
  .append("rect")
  .attr("x", function(d) { return d%25*20; })
  .attr("y", function(d) { return Math.floor(d/25)*20; })
  .attr("width", 20)
  .attr("height", 20)
  .attr("fill","none")
  .attr("stroke","#444")
  .attr("stroke-width", 1);
  
var startZoom = d3.zoomIdentity
  .translate(-250,-200)
  .scale(4);

var endZoom = d3.zoomIdentity
  .translate(-100,-100)
  .scale(5);
  
var zoom1 = d3.zoom().on("zoom", function() { g1.attr("transform", d3.event.transform); });
var zoom2 = d3.zoom().on("zoom", function() { g2.attr("transform", d3.event.transform); });

g1.call(zoom1.transform, startZoom);
g2.call(zoom2.transform, startZoom);

var toggle = true;

svg.on("click", function() {
  toggle = !toggle;
  g1.transition()
    .duration(5000)
    .call(zoom1.transform, toggle ? startZoom: endZoom)
    
  g2.transition()
    .duration(5000)
    .attr("transform", toggle ? startZoom: endZoom)
    .on("end", function() {
      d3.select(this).call(zoom2.transform,  toggle ? startZoom: endZoom);
    })
    
    
})
rect {
  opacity: 0.5;
}
<script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>

Upvotes: 6

Related Questions