Search code examples
javascriptd3.jssvgeasing

Transitioning a bar chart with negative values for the width


I am creating a horizontal bar chart using d3. And I am using an animation to "grow" the chart at startup. Here is the code.

// Create the svg element
d3.select("#chart-area")
    .append("svg")
    .attr("height", 800)
    .attr("width", 800);

    .data(dataValues) // This data is previously prepared
    .enter().append("rect")
    .style("fill", "blue")

    .attr("x", function () { return xScale(0); }) // xScale is defined earlier
    .attr("y", function (d) { return yScale(d); }) // yScale is defined earlier

    .attr("height", yScale.bandwidth()) // yScale is defined earlier

    // Initial value of "width" (before animation)
    .attr("width", 0)


    // Start of animation transition
    .transition()
    .duration(5000) // 5 seconds

    .ease (d3.easeLinear); 

    // Final value of "width" (after animation)
    .attr("width", function(d) { return Math.abs(xScale(d) - xScale(0)); })

The above code would work without any problem, and the lines would grow as intended, from 0 to whichever width, within 5 seconds.

Now, if we change the easing line to the following

    // This line changed
    .ease (d3.easeElasticIn); 

Then, the ease would try to take the width to a negative value before going to a final positive value. As you can see here, d3.easeElasticIn returns negative values as time goes by, then back to positive, resulting in width being negative at certain points in the animation. So the bars do not render properly (because SVG specs state that if width is negative, then use 0)

I tried every solution to allow the bars to grow negatively then back out. But could not find any. How can I fix this problem?

Thanks.


Solution

  • As you already know, the use of d3.easeElasticIn in your specific code will create negative values for the rectangles' width, which is not allowed.

    This basic demo reproduces the issue, the console (your browser's console, not the snippet's console) is populated with error messages, like this:

    Error: Invalid negative value for attribute width="-85.90933910798789"

    Have a look:

    const svg = d3.select("svg");
    
    const margin = 50;
    
    const line = svg.append("line")
      .attr("x1", margin)
      .attr("x2", margin)
      .attr("y1", 0)
      .attr("y2", 150)
      .style("stroke", "black")
    
    const data = d3.range(10).map(function(d) {
      return {
        y: "bar" + d,
        x: Math.random()
      }
    });
    
    const yScale = d3.scaleBand()
      .domain(data.map(function(d) {
        return d.y
      }))
      .range([0, 150])
      .padding(0.2);
    
    const xScale = d3.scaleLinear()
      .range([margin, 300]);
    
    const bars = svg.selectAll(null)
      .data(data)
      .enter()
      .append("rect")
      .attr("x", margin)
      .attr("width", 0)
      .style("fill", "steelblue")
      .attr("y", function(d) {
        return yScale(d.y)
      })
      .attr("height", yScale.bandwidth())
      .transition()
      .duration(2000)
      .ease(d3.easeElasticIn)
      .attr("width", function(d) {
        return xScale(d.x) - margin
      })
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <svg></svg>

    So, what's the solution?

    One of them is catching those negative values as they are generated and, then, moving the rectangle to the left (using the x attribute) and converting those negative numbers to positive ones.

    For that to work, we'll have to use attrTween instead of attr in the transition selection.

    Like this:

    .attrTween("width", function(d) {
        return function(t){
            return Math.abs(xScale(d.x) * t);
        };
    })
    .attrTween("x", function(d) {
        return function(t){
            return xScale(d.x) * t < 0 ? margin + xScale(d.x) * t : margin;
        };
    })
    

    In the snippet above, margin is just a margin that I created so you can see the bars going to the left of the axis.

    And here is the demo:

    const svg = d3.select("svg");
    
    const margin = 100;
    
    const line = svg.append("line")
      .attr("x1", margin)
      .attr("x2", margin)
      .attr("y1", 0)
      .attr("y2", 150)
      .style("stroke", "black")
    
    const data = d3.range(10).map(function(d) {
      return {
        y: "bar" + d,
        x: Math.random()
      }
    });
    
    const yScale = d3.scaleBand()
      .domain(data.map(function(d) {
        return d.y
      }))
      .range([0, 150])
      .padding(0.2);
    
    const xScale = d3.scaleLinear()
      .range([0, 300 - margin]);
    
    const bars = svg.selectAll(null)
      .data(data)
      .enter()
      .append("rect")
      .attr("x", margin)
      .attr("width", 0)
      .style("fill", "steelblue")
      .attr("y", function(d) {
        return yScale(d.y)
      })
      .attr("height", yScale.bandwidth())
      .transition()
      .duration(2000)
      .ease(d3.easeElasticIn)
      .attrTween("width", function(d) {
        return function(t) {
          return Math.abs(xScale(d.x) * t);
        };
      })
      .attrTween("x", function(d) {
        return function(t) {
          return xScale(d.x) * t < 0 ? margin + xScale(d.x) * t : margin;
        };
      })
    <script src="https://d3js.org/d3.v5.min.js"></script>
    <svg></svg>