1

I am using a D3.js to create a line chart using the general update pattern. I have two different types of data. The first type uses an abbreviated month key and the other the day of the month.

The problem I am experiencing is that the line doesn't transitions properly from one data type to the other. I have read some documentation and it stated that when updating d3 updates the lines using the index of each element. But you can change this by defining which variable D3 should watch when updating the chart.

So in order to accomplish this I declared inside the data function that D3 should use the key variable in the data arrays to check if two data points are the same. But in my snippet example at the bottom you can see that the update doesn't work properly. Instead of loading the full new line from the bottom. It transitions the first line into the second one but they clearly have a different key.

I have updated the code:

The problem wasn't explained correctly. I want to update the line where each point on the line should interpolate to the next point. Which in the snippet in the bottom is working. If it switches from the first to the second array, where all the keys are the same. The line should do as in the snippet and just interpolate.

But if I enter a completely new data with all new keys(like in the third array in the snippet), it should show the line which interpolates from the bottom(just like when entering the line the first time the application is loaded) of the chart and not interpolates from the its previous position. This is because in the project I am using the line also consists of points(circles) and these also transition from the bottom when using a new array.

      this.area = this.area
        .data([data], d => d.key)

new Vue({
  el: "#app",
  data() {
    return {
      index: 0,
      data: [
        [{
            key: "Jan",
            value: 5787
          },
          {
            key: "Feb",
            value: 6387
          },
          {
            key: "Mrt",
            value: 7375
          },
          {
            key: "Apr",
            value: 6220
          },
          {
            key: "Mei",
            value: 6214
          },
          {
            key: "Jun",
            value: 5205
          },
          {
            key: "Jul",
            value: 5025
          },
          {
            key: "Aug",
            value: 4267
          },
          {
            key: "Sep",
            value: 6901
          },
          {
            key: "Okt",
            value: 5800
          },
          {
            key: "Nov",
            value: 7414
          },
          {
            key: "Dec",
            value: 6547
          }
        ],
        [{
            key: "Jan",
            value: 4859
          },
          {
            key: "Feb",
            value: 5674
          },
          {
            key: "Mrt",
            value: 6474
          },
          {
            key: "Apr",
            value: 7464
          },
          {
            key: "Mei",
            value: 6454
          },
          {
            key: "Jun",
            value: 5205
          },
          {
            key: "Jul",
            value: 6644
          },
          {
            key: "Aug",
            value: 5343
          },
          {
            key: "Sep",
            value: 5363
          },
          {
            key: "Okt",
            value: 5800
          },
          {
            key: "Nov",
            value: 4545
          },
          {
            key: "Dec",
            value: 5454
          }
        ],
        [{
            "key": 1,
            "value": 4431
          },
          {
            "key": 2,
            "value": 5027
          },
          {
            "key": 3,
            "value": 4586
          },
          {
            "key": 4,
            "value": 7342
          },
          {
            "key": 5,
            "value": 6724
          },
          {
            "key": 6,
            "value": 6070
          },
          {
            "key": 7,
            "value": 5137
          },
          {
            "key": 8,
            "value": 5871
          },
          {
            "key": 9,
            "value": 6997
          },
          {
            "key": 10,
            "value": 6481
          },
          {
            "key": 11,
            "value": 5194
          },
          {
            "key": 12,
            "value": 4428
          },
          {
            "key": 13,
            "value": 4790
          },
          {
            "key": 14,
            "value": 5825
          },
          {
            "key": 15,
            "value": 4709
          },
          {
            "key": 16,
            "value": 6867
          },
          {
            "key": 17,
            "value": 5555
          },
          {
            "key": 18,
            "value": 4451
          },
          {
            "key": 19,
            "value": 7137
          },
          {
            "key": 20,
            "value": 5353
          },
          {
            "key": 21,
            "value": 5048
          },
          {
            "key": 22,
            "value": 5169
          },
          {
            "key": 23,
            "value": 6650
          },
          {
            "key": 24,
            "value": 5918
          },
          {
            "key": 25,
            "value": 5679
          },
          {
            "key": 26,
            "value": 5546
          },
          {
            "key": 27,
            "value": 6899
          },
          {
            "key": 28,
            "value": 5541
          },
          {
            "key": 29,
            "value": 7193
          },
          {
            "key": 30,
            "value": 5006
          },
          {
            "key": 31,
            "value": 6580
          }
        ]
      ]
    }
  },
  mounted() {
    // set the dimensions and margins of the graph
    var margin = {
        top: 20,
        right: 20,
        bottom: 30,
        left: 30
      },
      width = 500 - margin.left - margin.right;

    this.height = 200 - margin.top - margin.bottom;

    // 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
    this.svg = d3
      .select("#my_dataviz")
      .append("svg")
      .attr(
        "viewBox",
        `0 0 ${width + margin.left + margin.right} ${this.height +
          margin.top +
          margin.bottom}`
      )
      .attr("preserveAspectRatio", "xMinYMin")
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    // set the ranges
    this.xScale = d3
      .scalePoint()
      .range([0, width])
      .domain(
        this.data.map(function(d) {
          return d.key;
        })
      )
      .padding(0.5);

    this.yScale = d3.scaleLinear().rangeRound([this.height, 0]);

    this.yScale.domain([0, 7000]);

    // Draw Axis
    this.xAxis = d3.axisBottom(this.xScale);

    this.xAxisDraw = this.svg
      .append("g")
      .attr("class", "x axis")
      .attr("transform", `translate(0, ${this.height})`);

    this.yAxis = d3
      .axisLeft(this.yScale)
      .tickValues([0, 7000])
      .tickFormat(d => {
        if (d > 1000) {
          d = Math.round(d / 1000);
          d = d + "K";
        }
        return d;
      });

    this.yAxisDraw = this.svg.append("g").attr("class", "y axis");

    this.update(this.data[this.index]);
  },
  methods: {
    swapData() {
      if (this.index === 2) this.index = 0;
      else this.index++;
      this.update(this.data[this.index]);
    },
    update(data) {
      // Update scales.
      this.xScale.domain(data.map(d => d.key));
      this.yScale.domain([0, 7000]);

      // Set up transition.
      const dur = 1000;
      const t = d3.transition().duration(dur);

      // Update line.
      this.line = this.svg.selectAll(".line")
      this.line = this.line
        .data([data], d => d.key)
        .join(
          enter => {
            enter
              .append("path")
              .attr("class", "line")
              .attr("fill", "none")
              .attr("stroke", "#206BF3")
              .attr("stroke-width", 4)
              .attr(
                "d",
                d3
                .line()
                .x(d => {
                  return this.xScale(d.key);
                })
                .y(() => {
                  return this.yScale(0);
                })
              )
              .transition(t)
              .attr(
                "d",
                d3
                .line()
                .x(d => {
                  return this.xScale(d.key);
                })
                .y(d => {
                  return this.yScale(d.value);
                })
              );
          },

          update => {
            update.transition(t).attr(
              "d",
              d3
              .line()
              .x(d => {
                return this.xScale(d.key);
              })
              .y(d => {
                return this.yScale(d.value);
              })
            );
          },

          exit => exit.remove()
        );

      // Update Axes.
      this.yAxis.tickValues([0, 7000]);
      if (data.length > 12) {
        this.xAxis.tickValues(
          data.map((d, i) => {
            if (i % 3 === 0) return d.key;
            else return 0;
          })
        );
      } else {
        this.xAxis.tickValues(
          data.map(d => {
            return d.key;
          })
        );
      }
      this.yAxis.tickValues([0, 7000]);
      this.xAxisDraw.transition(t).call(this.xAxis.scale(this.xScale));
      this.yAxisDraw.transition(t).call(this.yAxis.scale(this.yScale));
    }
  }
})
<div id="app">
  <button @click="swapData">Swap</button>
  <div id="my_dataviz" class="flex justify-center"></div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<script src="https://d3js.org/d3.v6.js"></script>
Stephen
  • 913
  • 3
  • 24
  • 50

2 Answers2

1

[UPDATE: According to the comments the code was updated to change with a new line starting from the bottom when the set of keys in the new data are different]

here is a contribution for a better understanding of the problem, and a possible answer.

There is some misuse of the key element. When you define the key of the line, it's for d3 to know that one line is binded to that key. In this case, your key is binded to the path. When you add

this.line = this.line
        .data([data], d => d.key)

d3 binds the selection to [data] and will generate exactly one element ([data].length = 1) for this elements, d = data, hence d.key = null. This is the reason why you are not adding multiple lines, because your paths always got the key = null.

So, on the first time everything works as planned, you started a path as zero and then moves it to the final position with the transition.

This path has d attribute generate by the d3.line with a format like M x1 y1 L x2 y2 L x3 y3 ... L x12 y 12. Exactly 12 points for the first time.

When you swap the data, d3 will check the key (null again) and will consider this as an update.

So, it will interpolate the current path to a new one with the new data. The issue here is that there are no keys to bind the values. As you have now 31 points, it will interpolate the first 12 points (which is the part that you see moving) and add the remaining points (13 to 31). Of course, these last points don't have transition, because they didn't exist.

A possible solution for your case is to use a custom interpolator (that you can build) and use an attrTween to do the interpolation.

Fortunately, someone built one already: https://unpkg.com/d3-interpolate-path/build/d3-interpolate-path.min.js

SO here is a working solution

new Vue({
  el: "#app",
  data() {
    return {
      index: 0,
      data: [
        [{
            key: "Jan",
            value: 5787
          },
          {
            key: "Feb",
            value: 6387
          },
          {
            key: "Mrt",
            value: 7375
          },
          {
            key: "Apr",
            value: 6220
          },
          {
            key: "Mei",
            value: 6214
          },
          {
            key: "Jun",
            value: 5205
          },
          {
            key: "Jul",
            value: 5025
          },
          {
            key: "Aug",
            value: 4267
          },
          {
            key: "Sep",
            value: 6901
          },
          {
            key: "Okt",
            value: 5800
          },
          {
            key: "Nov",
            value: 7414
          },
          {
            key: "Dec",
            value: 6547
          }
        ],
        [{
            "key": 1,
            "value": 4431
          },
          {
            "key": 2,
            "value": 5027
          },
          {
            "key": 3,
            "value": 4586
          },
          {
            "key": 4,
            "value": 7342
          },
          {
            "key": 5,
            "value": 6724
          },
          {
            "key": 6,
            "value": 6070
          },
          {
            "key": 7,
            "value": 5137
          },
          {
            "key": 8,
            "value": 5871
          },
          {
            "key": 9,
            "value": 6997
          },
          {
            "key": 10,
            "value": 6481
          },
          {
            "key": 11,
            "value": 5194
          },
          {
            "key": 12,
            "value": 4428
          },
          {
            "key": 13,
            "value": 4790
          },
          {
            "key": 14,
            "value": 5825
          },
          {
            "key": 15,
            "value": 4709
          },
          {
            "key": 16,
            "value": 6867
          },
          {
            "key": 17,
            "value": 5555
          },
          {
            "key": 18,
            "value": 4451
          },
          {
            "key": 19,
            "value": 7137
          },
          {
            "key": 20,
            "value": 5353
          },
          {
            "key": 21,
            "value": 5048
          },
          {
            "key": 22,
            "value": 5169
          },
          {
            "key": 23,
            "value": 6650
          },
          {
            "key": 24,
            "value": 5918
          },
          {
            "key": 25,
            "value": 5679
          },
          {
            "key": 26,
            "value": 5546
          },
          {
            "key": 27,
            "value": 6899
          },
          {
            "key": 28,
            "value": 5541
          },
          {
            "key": 29,
            "value": 7193
          },
          {
            "key": 30,
            "value": 5006
          },
          {
            "key": 31,
            "value": 6580
          }
        ]
      ]
    }
  },
  mounted() {
    // set the dimensions and margins of the graph
    var margin = {
        top: 20,
        right: 20,
        bottom: 30,
        left: 30
      },
      width = 500 - margin.left - margin.right;

    this.height = 200 - margin.top - margin.bottom;

    // 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
    this.svg = d3
      .select("#my_dataviz")
      .append("svg")
      .attr(
        "viewBox",
        `0 0 ${width + margin.left + margin.right} ${this.height +
          margin.top +
          margin.bottom}`
      )
      .attr("preserveAspectRatio", "xMinYMin")
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    // set the ranges
    this.xScale = d3
      .scalePoint()
      .range([0, width])
      .domain(
        this.data.map(function(d) {
          return d.key;
        })
      )
      .padding(0.5);

    this.yScale = d3.scaleLinear().rangeRound([this.height, 0]);

    this.yScale.domain([0, 7000]);

    // Draw Axis
    this.xAxis = d3.axisBottom(this.xScale);

    this.xAxisDraw = this.svg
      .append("g")
      .attr("class", "x axis")
      .attr("transform", `translate(0, ${this.height})`);

    this.yAxis = d3
      .axisLeft(this.yScale)
      .tickValues([0, 7000])
      .tickFormat(d => {
        if (d > 1000) {
          d = Math.round(d / 1000);
          d = d + "K";
        }
        return d;
      });

    this.yAxisDraw = this.svg.append("g").attr("class", "y axis");

    this.update(this.data[this.index]);
  },
  methods: {
    swapData() {
      if (this.index === 0) this.index = 1;
      else this.index = 0;
      this.update(this.data[this.index]);
    },
    update(data) {
      // Update scales.
      this.xScale.domain(data.map(d => d.key));
      this.yScale.domain([0, 7000]);

      // Set up transition.
      const dur = 1000;
      const t = d3.transition().duration(dur);

      const line = d3
                .line()
                .x(d => {
                  return this.xScale(d.key);
                })
                .y((d) => {
                  return this.yScale(d.value);
                });

      // Update line.
      this.line = this.svg.selectAll(".line")
      this.line = this.line
        .data([data], d => d.reduce((key, elem) => key + '_' + elem.key, ''))
        .join(
          enter => {
            enter
              .append("path")
              .attr("class", "line")
              .attr("fill", "none")
              .attr("stroke", "#206BF3")
              .attr("stroke-width", 4)
              .attr(
                "d",
                d3
                .line()
                .x(d => {
                  return this.xScale(d.key);
                })
                .y(() => {
                  return this.yScale(0);
                })
              )
              .transition(t)
              .attr(
                "d", (d) => line(d)
              );
          },

          update => {
            update
            .transition(t)
            .attrTween('d', function(d) { 
                var previous = d3.select(this).attr('d');
                var current = line(d);
                return d3.interpolatePath(previous, current); 
            });
          },

          exit => exit.remove()
        );

      // Update Axes.
      this.yAxis.tickValues([0, 7000]);
      if (data.length > 12) {
        this.xAxis.tickValues(
          data.map((d, i) => {
            if (i % 3 === 0) return d.key;
            else return 0;
          })
        );
      } else {
        this.xAxis.tickValues(
          data.map(d => {
            return d.key;
          })
        );
      }
      this.yAxis.tickValues([0, 7000]);
      this.xAxisDraw.transition(t).call(this.xAxis.scale(this.xScale));
      this.yAxisDraw.transition(t).call(this.yAxis.scale(this.yScale));
    }
  }
})
<div id="app">
  <button @click="swapData">Swap</button>
  <div id="my_dataviz" class="flex justify-center"></div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<script src="https://d3js.org/d3.v6.js"></script>
<script src="https://unpkg.com/d3-interpolate-path/build/d3-interpolate-path.min.js"></script>
Carlos Moura
  • 727
  • 3
  • 8
  • Thank you for the answer! But It din't solve my problem. I have updated the answer because I feel like the question might need some more information in order to be understood better. – Stephen Feb 08 '21 at 09:01
  • so, you want to enter a new line, not switch between the lines? – Carlos Moura Feb 08 '21 at 10:26
  • If this is the case, you should add a new element to your data. Your problem is not only with d3 but with your data structure. You need to have the right data for d3 to draw the right lines. If you want to swap data, then you replace the data. If you want to add another line, your data array needs to have another element. eg: `.data([data])` will draw always one line. `.data([data1, data2])` will draw two lines – Carlos Moura Feb 08 '21 at 10:35
  • I do not need two lines. When the update function is called with data that has the same keys. Then the line should interpolate from its previous position to the position that represents the new data. When the new data contains a completely different array that contains different keys, then the line should interpolate from the bottom. Just like in bar charts that use the general update pattern. – Stephen Feb 08 '21 at 10:41
  • this is an easy solution, just update the binding key to use the keys in the array. eg `.data([data], d => d.reduce((key, elem) => key + '_' + elem.key, ''))`. I just updated the snippet to work like this – Carlos Moura Feb 08 '21 at 10:49
  • Thanks this fixed the problem! I will give you your bounty in 3 hours. But in the mean time can you please quickly explain what happens when you update the data function to `.data([data], d => d.reduce((key, elem) => key + '_' + elem.key, ''))` – Stephen Feb 08 '21 at 11:20
  • its generating a unique id from all your keys. d3 will allocate the id to the path. Basically the id = "Jan_Fev_Mar..._Dec", so if your data has the same keys, the keys concatenated will be the same. If your keys change, your id will change and d3 will remove the current line and add another one binded to a different id – Carlos Moura Feb 08 '21 at 13:14
  • Thanks. That is a nice solution! Do you think this interpolate functionality won't be added in the future as default in the D3 package? Because the interpolation of paths seems to me to be a crucial part when needing to update a path. – Stephen Feb 08 '21 at 13:20
  • This is ongoing for some time now, I don't think it'll be added. – Carlos Moura Feb 08 '21 at 13:56
  • @Stephen did you see my answer including the d3 interpolation plugin? – Ian Feb 08 '21 at 14:42
1

I'm not directly answering your question yet (sorry!) because this might be a better solution. It's possible to interpolate between lines with a different number of points, which may provide a better experience?

There's a d3-interpolate-path plugin that can handle a different number of points being present in the path, but still create a reasonably smooth animation by inserting placeholder points into the line.

There's a really good explanation of how this works, as well as some examples of it working https://bocoup.com/blog/improving-d3-path-animation .

Answer

If you really do want to animate from zero each time, then you need to check the keys match the last set of keys.

  1. Create a d3 local store

    const keyStore = d3.local();

  2. Get the keys from last render (element wants to be your line)

    const oldKeys = keyStore.get(element);

  3. Determine if the keys match:

    const newKeys = data.map(d => d.key);
    // arraysEqual - https://stackoverflow.com/a/16436975/21061
    const keysMatch = arraysEqual(oldKeys, newKeys);
    
  4. Change your interpolation on keysMatch (see previous ternary):

    update.transition(t)
          .attrTween('d', function(d) { 
              var previous = keysMatch ? d3.select(this).attr('d') : 0;
              var current = line(d);
              return d3.interpolatePath(previous, current); 
          });
    
    
Ian
  • 33,605
  • 26
  • 118
  • 198
  • Thanks for the detailed answer. I have viewed your answer. It seems a rather straight forward approach to tackle this problem. I don't seem to see any major differences to the other solution that is being brought forward. – Stephen Feb 08 '21 at 15:03
  • @Stephen no problem - I thought the existence of the d3-interpolate-path plugin might help you somewhat. But it not no worries :) – Ian Feb 08 '21 at 15:19
  • You are correct. It did help me a lot :D! But the previous answer also used the d3-interpolate-path plugin in order to fix this problem. Am I correct in saying that this plugin is always needed when updating a path? Doesn't that make it very necessary to include this or create an alternative solution in the default d3 package? I mean when you design a line chart you might want to make it responsive and able to update. – Stephen Feb 08 '21 at 15:23
  • 1
    @Stephen sorry, I hadn't seen the reference to the plugin there. You don't always need it, depends if your data keys are changing or not. – Ian Feb 08 '21 at 16:23