简体   繁体   中英

D3 General update pattern transition not working on pie chart

I have a project where I am using a Pie/Doughnut chart to visualize my data. I have added the general update pattern in order to create a smooth transition when my data changes/updates.

In order to accomplish this I have followed an example which uses the general update pattern on a pie chart: Bl.ocks example .

The problem I am facing is that the chart doesn't update smoothly when updating the data. The chart instantly swaps from one state into the next.

In this and other examples they define a arcTween method where d3 interpolates between the previous angles and the angles from the newly updated data:

    arcTween(a) {
      let i = d3.interpolate(this._current, a);
      this._current = i(0);
      return t => {
        return this.arc(i(t));
      };
    }

I also have added the code where I join, enter and update my data to the pie chart. Here I first create the group element where the pie is being drawn in. I also define a transition using the 'arcTween' method to transition between the states. And lastly I also define the 'this._current' when the pie is created:

      this.g = this.svg
        .selectAll("doughnut")
        .data(data_ready)
        .enter()
        .append("g");

      this.g
        .transition()
        .duration(1500)
        .attrTween("d", this.arcTween);

      this.g
        .append("path")
        .attr("d", d => {
          return this.arc(d);
        })
        .attr("fill", "#206BF3")
        .attr("class", "slice")
        .attr("stroke", "#2D3546")
        .style("stroke-width", "2px")
        .each(d => {
          this._current = d;
        });

This is my full code. This is being written in Vue.js. I have tried to get this to work inside a snippet. But I couldn't get it to work.

The images that are shown on top of the doughnut slices are locally stored:

<template>
  <div class="p-5 flex flex-col h-full">
    <h2 class="mb-3">{{ title }}</h2>
    <div ref="my_dataviz" class="flex justify-center"></div>
    <div class="grid grid-cols-2 gap-7 m-7">
      <div v-for="item in data" :key="item.key" class="flex">
        <img
          :src="require('@/assets/img/doughnut/' + item.icon)"
          alt=""
          class="doughnutIcon mr-4"
        />
        <div class="flex flex-col">
          <h3>{{ item.key }}</h3>
          <p class="opacity-50">
            {{ formatNumberValue(item.value) }} {{ unit }}
          </p>
          <p class="opacity-50">{{ percentageOfTotal(item.value) }} %</p>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import { converter } from "@/shared";
import * as d3 from "d3";

export default {
  name: "DoughnutChartItem",
  props: {
    title: {
      type: String,
      required: true
    },
    data: {
      type: Array,
      required: true
    },
    height: {
      type: Number,
      required: true
    },
    width: {
      type: Number,
      required: true
    },
    unit: {
      type: String
    }
  },
  data() {
    return {
      totalAmount: 0,
      svg: undefined,
      arc: undefined,
      radius: undefined,
      g: undefined
    };
  },
  created() {
    let total = 0;
    this.data.forEach(item => {
      total += item.value;
    });
    this.totalAmount = total;
  },
  mounted() {
    // set the dimensions and margins of the graph
    var margin = 1;

    // The radius of the pieplot is half the width or half the height (smallest one). I subtract a bit of margin.
    this.radius = Math.min(this.width, this.height) / 2 - margin;

    // append the svg object to the div called 'my_dataviz'
    this.svg = d3
      .select(this.$refs.my_dataviz)
      .append("svg")
      .attr("width", this.width)
      .attr("height", this.height)
      .append("g")
      .attr(
        "transform",
        "translate(" + this.width / 2 + "," + this.height / 2 + ")"
      );

    // Compute the position of each group on the pie:
    this.pie = d3.pie().value(function(d) {
      return d[1];
    });

    // declare an arc generator function
    this.arc = d3
      .arc()
      .outerRadius(100)
      .innerRadius(50);

    this.setSlicesOnDoughnut(this.data);

    this.addImagesToSlices();
  },
  methods: {
    animateSliceOnHover(radius, path, dir) {
      switch (dir) {
        case 0:
          path
            .transition()
            .duration(500)
            .ease(d3.easeBounce)
            .attr(
              "d",
              d3
                .arc()
                .innerRadius(100)
                .outerRadius(50)
            );
          path.style("fill", "#206BF3");
          break;

        case 1:
          path.transition().attr(
            "d",
            d3
              .arc()
              .innerRadius(50)
              .outerRadius(110)
          );
          path.style("fill", "white");
          break;
      }
    },
    percentageOfTotal(amount) {
      return Math.round((amount / this.totalAmount) * 100);
    },
    formatNumberValue(amount) {
      return converter.formatNumberValue(amount);
    },
    setSlicesOnDoughnut(data) {
      // Build the pie chart: Basically, each part of the pie is a path that we build using the arc function.
      var data_ready = this.pie(
        data.map(function(d) {
          return [d["key"], d["value"], d["icon"], d["hover"]];
        })
      );

      this.g = this.svg
        .selectAll("doughnut")
        .data(data_ready)
        .enter()
        .append("g");

      this.g
        .transition()
        .duration(1500)
        .attrTween("d", this.arcTween);

      this.g
        .append("path")
        .attr("d", d => {
          return this.arc(d);
        })
        .attr("fill", "#206BF3")
        .attr("class", "slice")
        .attr("stroke", "#2D3546")
        .style("stroke-width", "2px")
        .each(d => {
          this._current = d;
        });

      // Add tooltip
      d3.selectAll(".slice")
        .on("mouseover", this.mouseover)
        .on("mousemove", this.mousemove)
        .on("mouseout", this.mouseout);
    },
    addImagesToSlices() {
      var image_width = 20;
      var image_height = 20;

      this.g.selectAll(".logo").remove();

      this.g
        .append("svg:image")
        .attr("transform", d => {
          var x = this.arc.centroid(d)[0] - image_width / 2;
          var y = this.arc.centroid(d)[1] - image_height / 2;
          return "translate(" + x + "," + y + ")";
        })
        .attr("class", "logo")
        .attr("class", function(d) {
          return `${d.data[0]}-logo`;
        })
        .attr("href", function(d) {
          return require("@/assets/img/doughnut/" + d.data[2]);
        })
        .attr("width", image_width)
        .attr("height", image_height);
    },
    mouseover(event, data) {
      //Swap doughnut icon to blue icon
      d3.selectAll("." + data.data[0] + "-logo").attr("href", d => {
        return require("@/assets/img/doughnut/" + d.data[3]);
      });

      this.animateSliceOnHover(this.radius, d3.select(event.currentTarget), 1);

      const tip = d3.select(".tooltip");

      tip
        .style("left", `${event.clientX + 15}px`)
        .style("top", `${event.clientY}px`)
        .transition()
        .style("opacity", 0.98);

      tip.select("h3").html(`${data.data[0]}`);
      tip
        .select("h4")
        .html(`${this.formatNumberValue(data.data[1])} ${this.unit}`);
    },
    mousemove(event) {
      // Move tooltip
      d3.select(".tooltip")
        .style("left", `${event.clientX + 15}px`)
        .style("top", `${event.clientY}px`);
    },
    mouseout(event, data) {
      //Swap doughnut icon to white icon
      d3.selectAll("." + data.data[0] + "-logo").attr("href", function(d) {
        return require("@/assets/img/doughnut/" + d.data[2]);
      });

      // Animate slice
      var thisPath = d3.select(event.currentTarget);
      this.animateSliceOnHover(this.radius, thisPath, 0);

      // if (!thisPath.classed("clicked")) {
      //   this.animateSliceOnHover(this.radius, thisPath, 0);
      // }

      // Hide tooltip
      d3.select(".tooltip")
        .transition()
        .style("opacity", 0);
    },
    arcTween(a) {
      let i = d3.interpolate(this._current, a);
      this._current = i(0);
      return t => {
        return this.arc(i(t));
      };
    }
  },
  watch: {
    data() {
      this.setSlicesOnDoughnut(this.data);

      this.addImagesToSlices();
    }
  }
};
</script>

I have shortened the code and included into a code snippet:

 new Vue({ el: "#app", data() { return { index: 0, data: [ [ { key: "one", value: 123 }, { key: "two", value: 232 }, { key: "three", value: 186 } ], [ { key: "one", value: 145 }, { key: "two", value: 270 }, { key: "three", value: 159 } ], ] } }, mounted() { // set the dimensions and margins of the graph var margin = 1; // The radius of the pieplot is half the width or half the height (smallest one). I subtract a bit of margin. this.radius = Math.min(this.width, this.height) / 2 - margin; this.width = 250; this.height = 250; // append the svg object to the div called 'my_dataviz' this.svg = d3.select("#my_dataviz").append("svg").attr("width", this.width).attr("height", this.height).append("g").attr( "transform", "translate(" + this.width / 2 + "," + this.height / 2 + ")" ); // Compute the position of each group on the pie: this.pie = d3.pie().value(function(d) { return d[1]; }); // declare an arc generator function this.arc = d3.arc().outerRadius(100).innerRadius(50); this.setSlicesOnDoughnut(); },methods: { swapData() { if(this.index === 0) this.index = 1; else this.index = 0; this.setSlicesOnDoughnut(); }, setSlicesOnDoughnut() { // Build the pie chart: Basically, each part of the pie is a path that we build using the arc function. var data_ready = this.pie( this.data[this.index].map(function(d) { return [d["key"], d["value"]]; }) ); console.log(data_ready); // join var arcs = this.svg.selectAll(".arc").data(data_ready); // update arcs.transition().duration(1500).attrTween("d", this.arcTween); // enter arcs.enter().append("path").attr("class", "arc").attr("fill", "#206BF3").attr("stroke", "#2D3546").style("stroke-width", "2px").attr("d", this.arc).each((d, i, n) => { n[i]._current = d; }); } }, arcTween(a) { var i = d3.interpolate(this._current, a); this._current = i(0); return t => { return this.arc(i(t)); }; } })
 <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>

The snippet that works, but the slices change position:

 new Vue({ el: "#app", data() { return { index: 0, data: [ [{ key: "one", value: 123 }, { key: "two", value: 232 }, { key: "three", value: 186 }, { key: "four", value: 238 } ], [{ key: "one", value: 145 }, { key: "two", value: 270 }, { key: "three", value: 159 }, { key: "four", value: 168 } ], ] } }, mounted() { // set the dimensions and margins of the graph var margin = 1; // The radius of the pieplot is half the width or half the height (smallest one). I subtract a bit of margin. this.radius = Math.min(this.width, this.height) / 2 - margin; this.width = 250; this.height = 250; // append the svg object to the div called 'my_dataviz' this.svg = d3.select("#my_dataviz").append("svg").attr("width", this.width).attr("height", this.height).append("g").attr( "transform", "translate(" + this.width / 2 + "," + this.height / 2 + ")" ); // Compute the position of each group on the pie: this.pie = d3.pie().value(function(d) { return d[1]; }); // declare an arc generator function this.arc = d3.arc().outerRadius(100).innerRadius(50); this.setSlicesOnDoughnut(); }, methods: { swapData() { if (this.index === 0) this.index = 1; else this.index = 0; this.setSlicesOnDoughnut(); }, arcTween(a, j, n) { var i = d3.interpolate(n[j]._current, a); n[j]._current = i(0); return t => { return this.arc(i(t)); }; }, setSlicesOnDoughnut() { // Build the pie chart: Basically, each part of the pie is a path that we build using the arc function. var data_ready = this.pie( this.data[this.index].map(function(d) { return [d["key"], d["value"]]; }) ); //console.log(data_ready); // join var arcs = this.svg.selectAll(".arc").data(data_ready); // update arcs.transition().duration(1500).attrTween("d", this.arcTween); // enter arcs.enter().append("path").attr("class", "arc").attr("fill", "#206BF3").attr("stroke", "#2D3546").style("stroke-width", "2px").attr("d", this.arc).each((d, i, n) => { n[i]._current = d; }); } } })
 <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>

Two changes made your snippet work.

The first one was moving the arcTween method up in the methods object. Since I'm not a Vue user and as this makes no sense from a JS perspective (the object's properties order shouldn't matter), I'll leave that part for you to investigate.

The second change is just related to the meaning of this . You're mixing this as the method object (as in this.arc ) and this as the DOM element (as in this._current ). Like I said in my comment , just use the third and second arguments combined to refer to the DOM element (in the vast majority of D3 methods).

That said, this...

arcTween(a) {
      var i = d3.interpolate(this._current, a);
      this._current = i(0);
      return t => {
        return this.arc(i(t));
      };
    }

...should be:

arcTween(a, j, n) {
  var i = d3.interpolate(n[j]._current, a);
  n[j]._current = i(0);
  return t => {
    return this.arc(i(t));
  };
}

Here is your snippet with those two changes:

 new Vue({ el: "#app", data() { return { index: 0, data: [ [{ key: "one", value: 123 }, { key: "two", value: 232 }, { key: "three", value: 186 } ], [{ key: "one", value: 145 }, { key: "two", value: 270 }, { key: "three", value: 159 } ], ] } }, mounted() { // set the dimensions and margins of the graph var margin = 1; // The radius of the pieplot is half the width or half the height (smallest one). I subtract a bit of margin. this.radius = Math.min(this.width, this.height) / 2 - margin; this.width = 250; this.height = 250; // append the svg object to the div called 'my_dataviz' this.svg = d3.select("#my_dataviz").append("svg").attr("width", this.width).attr("height", this.height).append("g").attr( "transform", "translate(" + this.width / 2 + "," + this.height / 2 + ")" ); // Compute the position of each group on the pie: this.pie = d3.pie().value(function(d) { return d[1]; }); // declare an arc generator function this.arc = d3.arc().outerRadius(100).innerRadius(50); this.setSlicesOnDoughnut(); }, methods: { swapData() { if (this.index === 0) this.index = 1; else this.index = 0; this.setSlicesOnDoughnut(); }, arcTween(a, j, n) { var i = d3.interpolate(n[j]._current, a); n[j]._current = i(0); return t => { return this.arc(i(t)); }; }, setSlicesOnDoughnut() { // Build the pie chart: Basically, each part of the pie is a path that we build using the arc function. var data_ready = this.pie( this.data[this.index].map(function(d) { return [d["key"], d["value"]]; }) ); //console.log(data_ready); // join var arcs = this.svg.selectAll(".arc").data(data_ready); // update arcs.transition().duration(1500).attrTween("d", this.arcTween); // enter arcs.enter().append("path").attr("class", "arc").attr("fill", "#206BF3").attr("stroke", "#2D3546").style("stroke-width", "2px").attr("d", this.arc).each((d, i, n) => { n[i]._current = d; }); } } })
 <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>

the code really works, you can see the fiddle https://jsfiddle.net/1Lk8zqud/1/

If you really need to have the containers, then you need to have some considerations:

  1. You need to consider the containers with the full d3 update pattern:
  2. New containers -> set the attr d, append a path, append an image 3 - Existing containers -> no appends, just update the path and image 4 - remove the containers not needed

In case of new containers, remember that they are added with the final path (no transitions), so its better to lower the old containers in order to dont mess up with the transitions. I didn't try it with images.

So here is the new version

const g = svg.selectAll('g.arcs')
    .data(data_ready, function(d) { return d.index; });

//  new data -> add arcs
    g.enter().append('g')
      .attr('class', 'arcs')
      .lower()
      .append('path')
        .attr("fill", "#206BF3")
        .attr("class", "slice")
        .attr("stroke", "#2D3546")
        .style("stroke-width", "2px")
        .transition()
        .duration(1500)
        .attrTween("d", function(a) {
            let i = d3.interpolate(this._current, a);
            this._current = i(0);
            return function(t) {
                return this.arc(i(t));
            };
       });
       
// old data -> transition data on the container g (for using with the images). Don't add arcs
   g.raise()
       .transition()
       .duration(1500)
       .attrTween("d", function(a) {
            let i = d3.interpolate(this._current, a);
            this._current = i(0);
            return function(t) {
                return this.arc(i(t));
            };
       });
       
       // transition the path inside the old containers g
       g.select('path').transition()
       .duration(1500)
       .attrTween("d", function(a) {
            let i = d3.interpolate(this._current, a);
            this._current = i(0);
            return function(t) {
                return this.arc(i(t));
            };
       })

  g.exit().lower().transition().duration(1500).remove();```


A working version of this code:
https://jsfiddle.net/rtLbyn52/

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM