Search code examples
animationd3.jshighchartslinearea

How to synchronize animation of path and area?


I'm getting stuck with the D3.js v4's animation of both line & area:

  • It's ok to do the animation separately for line & area
  • When 2 animations are combined, even at the same transition duration, they do not occur together.
  • For the reason of styling, I cannot drop the line away.

See the illustration below: enter image description here

To make thing like above, I do 2 big steps:

  1. Do animation for line via setting the properties stroke-dasharrow and stroke-dashoffset. (Inspired from Visual Cinnamon)
  2. Do animation for area via tweaking parameters for d3.area() function (Inspired from other Stackoverlfow post)

The result is rather disappointing because line and area underneath do not appear in parallel.

My target is to mimic the Highchart library, see an example here, and its illustration below:

enter image description here

It seems the Highchart library uses a different animation technique, because during DOM inspection, there is no sign of any change for the DOM paths along the animation.

Appreciated if anyone could suggest me some ideas to experiment with.

My code sample is below:

let animationDuration = 5000;
// set the dimensions and margins of the graph
var margin = { top: 20, right: 20, bottom: 30, left: 50 },
    width = 480 - margin.left - margin.right,
    height = 250 - margin.top - margin.bottom;

// parse the date / time
var parseTime = d3.timeParse("%d-%b-%y");

// set the ranges
var x = d3.scaleTime().range([0, width]);
var y = d3.scaleLinear().range([height, 0]);

// define the area
var area = function (datum, boolean) {
    return d3.area()
        .y0(height)
        .y1(function (d) { return boolean ? y(d.close) : y(d.close); })
        .x(function (d) { return boolean ? x(d.date) : 0; })
        (datum);
}

// define the line
var valueline = d3.line()
    .x(function (d) { return x(d.date); })
    .y(function (d) { return y(d.close); });

// append the svg obgect to the body of the page
// appends a 'group' element to 'svg'
// moves the 'group' element to the top left margin
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 + ")");

      var data = d3.csvParse(d3.select("pre#data").text());

      data.reverse();

    // format the data
    data.forEach(function (d) {
        d.date = parseTime(d.date);
        d.close = +d.close;
    });

    // scale the range of the data
    x.domain(d3.extent(data, function (d) { return d.date; }));
    y.domain([0, d3.max(data, function (d) { return d.close; })]);

    // add the area
    svg.append("path")
        .data([data])
        .attr("class", "area")
        .attr("d", d => area(d, false))
        .attr("fill", "lightsteelblue")
        .transition()
        .duration(animationDuration)
        .attr("d", d => area(d, true));

    // add the valueline path.
    svg.append("path")
        .data([data])
        .attr("class", "line")
        .attr("d", valueline)
        .style("stroke-dasharray", d => {
            let path = document.querySelector(".line");
            const totalLength = path.getTotalLength();

            return `${totalLength} ${totalLength}`;
        })
        .style("stroke-dashoffset", d => {
            let path = document.querySelector(".line");
            const totalLength = path.getTotalLength();
            return `${totalLength}`;
        })
        .transition()
        .duration(animationDuration)
        .style("stroke-dashoffset", 0);

    // add the X Axis
    svg.append("g")
        .attr("transform", "translate(0," + height + ")")
        .call(d3.axisBottom(x));

    // add the Y Axis
    svg.append("g")
        .call(d3.axisLeft(y));
.line {
        fill: none;
        stroke: steelblue;
        stroke-width: 2px;
    }
    
pre#data {display:none;}
<script src="https://d3js.org/d3.v4.min.js"></script>
<pre id="data">
date,close
1-May-12,58.13
30-Apr-12,53.98
27-Apr-12,67.00
26-Apr-12,89.70
25-Apr-12,99.00
24-Apr-12,130.28
23-Apr-12,166.70
20-Apr-12,234.98
19-Apr-12,345.44
18-Apr-12,443.34
17-Apr-12,543.70
16-Apr-12,580.13
13-Apr-12,605.23
12-Apr-12,622.77
11-Apr-12,626.20
10-Apr-12,628.44
9-Apr-12,636.23
5-Apr-12,633.68
4-Apr-12,624.31
3-Apr-12,629.32
2-Apr-12,618.63
30-Mar-12,599.55
29-Mar-12,609.86
28-Mar-12,617.62
27-Mar-12,614.48
26-Mar-12,606.98
</pre>


Solution

  • There is a way to animate both the line and the area, using a custom interpolator.

    However, since your goal is to mimic that Highcharts animation you linked, there is a way easier alternative: use a <clipPath>.

    In my proposed solution we create the area and the line the regular way. However, we reference a clipping path...

    .attr("clip-path", "url(#clip)");
    

    ...in both area and line. The clipping path is created with 0 width:

    var clip = svg.append("clipPath")
        .attr("id", "clip");
    var clipRect = clip.append("rect")
      .attr("width", 0)
    

    Then, after that, it's just a matter of applying the transition to the clipping path:

    clipRect.transition()
        .duration(5000)
        .ease(d3.easeLinear)
        .attr("width", someValue)
    

    Here is a demo:

    var svg = d3.select("svg");
    var data = d3.range(30).map(d => Math.random() * 150);
    var clip = svg.append("clipPath")
      .attr("id", "clip");
    var clipRect = clip.append("rect")
      .attr("width", 0)
      .attr("height", 150)
    var lineGenerator = d3.line()
      .x((_, i) => i * 10)
      .y(d => d)
      .curve(d3.curveMonotoneX)
    var areaGenerator = d3.area()
      .x((_, i) => i * 10)
      .y1(d => d)
      .y0(150)
      .curve(d3.curveMonotoneX)
    svg.append("path")
      .attr("d", areaGenerator(data))
      .attr("class", "area")
      .attr("clip-path", "url(#clip)");
    svg.append("path")
      .attr("d", lineGenerator(data))
      .attr("class", "line")
      .attr("clip-path", "url(#clip)");
    clipRect.transition()
      .duration(5000)
      .ease(d3.easeLinear)
      .attr("width", 300)
    .line {
      fill: none;
      stroke: #222;
      stroke-width: 2px;
    }
    
    .area {
      fill: limegreen;
      stroke: none;
    }
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <svg></svg>

    And here is your code with those changes:

    let animationDuration = 5000;
    // set the dimensions and margins of the graph
    var margin = {
        top: 20,
        right: 20,
        bottom: 30,
        left: 50
      },
      width = 480 - margin.left - margin.right,
      height = 250 - margin.top - margin.bottom;
    
    // parse the date / time
    var parseTime = d3.timeParse("%d-%b-%y");
    
    // set the ranges
    var x = d3.scaleTime().range([0, width]);
    var y = d3.scaleLinear().range([height, 0]);
    
    // define the area
    var area = function(datum, boolean) {
      return d3.area()
        .y0(height)
        .y1(function(d) {
          return boolean ? y(d.close) : y(d.close);
        })
        .x(function(d) {
          return boolean ? x(d.date) : 0;
        })
        (datum);
    }
    
    // define the line
    var valueline = d3.line()
      .x(function(d) {
        return x(d.date);
      })
      .y(function(d) {
        return y(d.close);
      });
    
    // append the svg obgect to the body of the page
    // appends a 'group' element to 'svg'
    // moves the 'group' element to the top left margin
    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 + ")");
    
    var clip = svg.append("clipPath")
      .attr("id", "clip");
    var clipRect = clip.append("rect")
      .attr("width", 0)
      .attr("height", height);
    
    var data = d3.csvParse(d3.select("pre#data").text());
    
    data.reverse();
    
    // format the data
    data.forEach(function(d) {
      d.date = parseTime(d.date);
      d.close = +d.close;
    });
    
    // scale the range of the data
    x.domain(d3.extent(data, function(d) {
      return d.date;
    }));
    y.domain([0, d3.max(data, function(d) {
      return d.close;
    })]);
    
    // add the area
    svg.append("path")
      .data([data])
      .attr("class", "area")
      .attr("d", d => area(d, true))
      .attr("fill", "lightsteelblue")
      .attr("clip-path", "url(#clip)");
    
    // add the valueline path.
    svg.append("path")
      .data([data])
      .attr("class", "line")
      .attr("d", valueline)
      .attr("clip-path", "url(#clip)");
    
    // add the X Axis
    svg.append("g")
      .attr("transform", "translate(0," + height + ")")
      .call(d3.axisBottom(x));
    
    // add the Y Axis
    svg.append("g")
      .call(d3.axisLeft(y));
    
    clipRect.transition()
      .duration(5000)
      .ease(d3.easeLinear)
      .attr("width", width)
    .line {
      fill: none;
      stroke: steelblue;
      stroke-width: 2px;
    }
    
    pre#data {
      display: none;
    }
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <pre id="data">
    date,close
    1-May-12,58.13
    30-Apr-12,53.98
    27-Apr-12,67.00
    26-Apr-12,89.70
    25-Apr-12,99.00
    24-Apr-12,130.28
    23-Apr-12,166.70
    20-Apr-12,234.98
    19-Apr-12,345.44
    18-Apr-12,443.34
    17-Apr-12,543.70
    16-Apr-12,580.13
    13-Apr-12,605.23
    12-Apr-12,622.77
    11-Apr-12,626.20
    10-Apr-12,628.44
    9-Apr-12,636.23
    5-Apr-12,633.68
    4-Apr-12,624.31
    3-Apr-12,629.32
    2-Apr-12,618.63
    30-Mar-12,599.55
    29-Mar-12,609.86
    28-Mar-12,617.62
    27-Mar-12,614.48
    26-Mar-12,606.98
    </pre>