Search code examples
javascriptreactjsd3.jszooming

d3 v5 Axis Scale Change Panning Way Too Much


I have a simple chart with time as the X axis. The intended behavior is that while dragging in the graph, the X axis only will pan to show other parts of the data.

For convenience, since my X axis is in a react component, the function that creates my chart sets the X scale, the x axis, and the element it is attached to as this.xScale, this.xAxis, and this.gX, respectively.

If I set this as the content of my zoom method, everything works fine:

        this.gX.call(this.xAxis.scale(d3.event.transform.rescaleX(this.xScale)))

The X axis moves smoothly with touch input. However, this doesn't work for me, because later when I update the chart (moving data points in response to the change of the axis), I need this.xAxis to be changed so the points will map to different locations.

So, I then set the content of my zoom method to this:

        this.xScale = d3.event.transform.rescaleX(this.xScale);
        this.xAxis = this.xAxis.scale(this.xScale);
        this.gX.call(this.xAxis);

As far as I can tell, this should function EXACTLY the same way. However, when I use this code, even without running my updateChart() function (updating the data points), the X axis scales erratically when panning, way more than normal. My X axis is based on time, so suddenly a time domain from 2014 to 2018 includes the early 1920s.

What am I doing wrong?


Solution

  • Problem

    When you use scale.rescaleX you are modifying a scale's domain based on a current zoom transform (based on translate and scale).

    But, the transform returned from d3.event.transfrom isn't the change from the previous zoom transform, it represents the cumulative transformation. We want to apply this transform on our original scale as the transform represents the change from the original state. However, you are applying this cumulative transform on a scale that was modified by previous zoom transforms:

         this.xScale = d3.event.transform.rescaleX(this.xScale);
    

    Let's work through what this does during a translate event such as panning:

    1. Pan right 10 units
    2. Shift the domain of the scale 10 units.

    That works, but if we pan again:

    1. Pan right 10 more units
    2. Shift the domain of the scale an additional 20 units.

    Why? Because the zoom transform is keeping track of the zoom state relative to the initial state, but you want to update the scale with only the change in state, not the cumulative change to the zoom transform. Consequently, at this point the domain has shifted 30 units, but the user has only panned 20.

    The same thing happens with scale:

    1. Zoom in by 2x on the center of the graph (zoom transform scale = 2)
    2. Rescale the scale so that it has half the domain (is twice as detailed)
    3. Zoom in again by 2x on the center of the graph (zoom transform scale = 4)
    4. Rescale the scale so that it has one one fourth the domain that it currently has (which is already one half of the original, so we are now zoomed in 8x: 2x4).

    At step four, d3.event.transform.k == 4, and rescaleX is now scaling the scale by a factor of four, it doesn't "know" that the scale has already been scaled by a factor of two.

    It gets even worse if we continue to apply zooms, for example if we zoom out from k=4 to k=2, d3.event.transform.k == 2, we are still zooming in 2x despite trying to zoom out, now we are at 16x: 2x4x2. If instead we zoom in, we get 64x (2x4x8)

    This effect is particularly bad on a translate - the zoom even is triggered constantly throughout a pan event, so the scale is cumulatively reapplied on a scale that already has cumulatively applied the zoom transform. A pan can easily trigger dozens of zoom events. In the comparison snippet below, panning just a bit can easily pull you into the 1920s despite a starting domain of 2014-2018.

    Solution

    The easiest way to correct this (and the canonical way) is very similar to the approach you use in your code that works for panning (but not updating):

     this.gX.call(this.xAxis.scale(d3.event.transform.rescaleX(this.xScale)))
    

    What are we doing here? We are creating a new scale while keeping the original the same - d3.event.transform.rescaleX(this.xScale). We supply the new scale to the axis. But, as you note, when updating the graph you run into problems, xScale isn't the scale used by the axis, as we now have two disparate scales.

    The solution then is to use, what I call, a reference scale and a working scale. The reference scale will be used to update a working scale based on the current zoom transform. The working scale will be used whenever creating/updating axes or points. At the beginning, both scales will probably be the same so we can create the scale as so:

    var xScale = d3.scaleLinear().domain(...).range(...) // working
    var xScaleReference = xScale.copy();                 // reference
    

    We can update or place elements with xScale, as usual.

    On zoom, we can update xScale (and the axis) with:

    xScale = d3.event.transform.rescaleX(xScaleReference)
    xAxis.scale(xScale);
    selection.call(xAxis);
    

    Here's a comparison, it has the same domain as you note, but it doesn't take long to get to the 1920s on the upper scale (which uses one scale). The bottom is much more as expected (and makes use of a working and reference scale):

    var svg = d3.select("body")
      .append("svg")
      .attr("width", 400)
      .attr("height", 200);
    
    var parseTime = d3.timeParse("%Y")
     
     
    var start = parseTime("2014");
    var end = parseTime("2018");
     
    ///////////////////
    // Single scale updated by zoom transform:
    var a = d3.scaleTime()
      .domain([start,end])
      .range([20,380])
      
    var aAxis = d3.axisBottom(a).ticks(5);
    
    var aAxisG = svg.append("g")
      .attr("transform","translate(0,30)")
      .call(aAxis);
    
    /////////////////
    // Reference and working scale:
    var b = d3.scaleTime()
      .domain([start,end])
      .range([20,380])
      
    var bReference = b.copy();
    
    var bAxis = d3.axisBottom(b).ticks(5);
      
    var bAxisG = svg.append("g")
      .attr("transform","translate(0,80)")
      .call(bAxis);
    
    /////////////////
    // Zoom:
    var zoom = d3.zoom()
      .on("zoom", function() {
        a = d3.event.transform.rescaleX(a);
        b = d3.event.transform.rescaleX(bReference);
        
        aAxisG.call(aAxis.scale(a));
        bAxisG.call(bAxis.scale(b));
    
    
    })
    
    svg.call(zoom);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

    We can see the same approach taken with Mike Bostock's examples such as this brush and zoom, where x2 and y2 represent the reference scales and x and y represent the working scales.