D3 update bar chart VUE.js

Viewed 1336

Hey I have a problem with d3 chart in Vue. Everything is loading fine except updating data.

I'm using props to pass the data and as the data is changing my chart instead of updating it creates another one just below the current one.

What I wanna do is to keep the current graph and update only the bars as data is changing

<script>
export default {
    props: {
      arr: {
          type: Array
      }, 
      colors: {
          type: Array
      }
  },
  watch: {
    arr: {
      immediate: true,
      handler(val) {
        setTimeout(() => {
          this.barCharts(val)
        }, 100);
      }
    }
  },
  methods: {
    barCharts(data) {
      let margin = { top: 40, right: 20, bottom: 50, left: 60 };
      let width = 650
      let height = 240; 

      let color = d3.scaleOrdinal().range(this.colors);
      let t = d3.transition().duration(750);

      let g = d3
        .select("#barChart")
        .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", "100%")
        .append("g")
        .attr("transform", `translate(${margin.left}, ${margin.top})`);

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

      let yAxisGroup = g.append("g").attr("class", "y axis");

      // X Scale
      let x = d3
        .scaleBand()
        .range([0, width])
        .padding(0.2);

      // Y Scale
      let y = d3.scaleLinear().range([height, 0]);

      // axis
      x.domain(data.map(d => d.name));
      y.domain([
        0,
        d3.max(data, function(d) {
          return d.value;
        })
      ]);

      g.append("g")
        .attr("transform", "translate(0," + height + ")")
        .call(d3.axisBottom(x))
        .attr("class", "axis")
        .selectAll("text")
        .style("text-anchor", "end")
        .attr("dx", "-.8em")
        .attr("dy", ".15em")
        .attr("transform", "rotate(-30)");

      g.append("g")
        .call(d3.axisLeft(y))
        .attr("class", "axis");

      // draw bars
      let rects = g.selectAll("rect").data(data);

      rects
        .transition(t)
        .attr("y", function(d) {
          return y(d.value);
        })
        .attr("x", function(d) {
          return x(d.name);
        })
        .attr("height", function(d) {
          return height - y(d.value);
        })
        .attr("width", x.bandwidth())
        .attr("fill", (d, i) => color(i));

      rects
        .enter()
        .append("rect")
        .attr("x", function(d) {
          return x(d.name);
        })
        .attr("width", x.bandwidth())
        .attr("fill", (d, i) => color(i))
        .attr("y", y(0))
        .attr("height", 0)
        .transition(t)
        .attr("y", function(d) {
          return y(d.value);
        })
        .attr("height", function(d) {
          return height - y(d.value);
        });

    }
  }
};
}
</script>

Can I implement another function ex. update to call inside of this function to update only the bard??

Thanks in advance!

2 Answers

You generate a new bar graph because you're appending a new SVG element to your target in the barCharts method. Every time it's called, it will append a new SVG with the updated data, causing your issue of duplicate charts.

Since Vue has its own means of rendering and updating DOM elements, you shouldn't use d3 to do that, as that messes with Vue reactivity and cleanup (meaning you may end up having memory leaks or elements not being removed).

My advice is to define your <svg>, <g> and other elements in your vue <template>, and use the v-bind directive to pass your bar graph values to the vue-rendered SVG elements. For example you can use the methods from d3-scale to create x, y, and barColor methods/computed properties in your component. Then you can bind the results for each bar to the correct properties in the template, for example <rect v-for="(bar, index) in bars" :x="x(bar.name)" :y="y(bar.value)" :width="x.bandwidth()" :height="height - y(bar.value)" :fill="barColor(index)" /> or similar.

Let me know if this is sufficient for you to get on your way, otherwise I'll be able to write you a full example later today. I also recommend these videos on combining vue and d3, which were very insightful for me:

I've changed that according to your suggestion and I can admit it's brilliant!! It's working perfectly just have one issue with transition. I'm not quite sure where I should place that... On enter I want to make transition y from 0 to the height of the bar and then make another transition when the data changes.

I would appreciate your help again!

<template>
  <svg class="barChart" :width="width" height="340">
    <g>
    <!-- axis bottom -->
       <g class="x-axis" fill="none" :transform="`translate(0, ${height})`">
        <path stroke="currentColor" :d="`M0.5,6V0.5H${width}.5V6`"></path>
        <g
          class="tick"
          opacity="1"
          font-size="10"
          font-family="sans-serif"
          text-anchor="middle"
          v-for="(bar, index) in bars"
          :key="index"
          :transform="`translate(${bar.x + bar.width / 2}, 0)`"
        >
          <line stroke="currentColor" y2="6"></line>
          <text fill="currentColor" y="9" dy="0.71em">{{ bar.xLabel }}</text>
        </g>
      </g>

      <!-- y-axis -->
      <g class="y-axis" fill="none" :transform="`translate(0, 0)`">
        <path
          class="domain"
          stroke="currentColor"
          :d="`M0.5,${height}.5H0.5V0.5H-6`"
        ></path>
        <g
          class="tick"
          opacity="1"  
          font-size="10"
          font-family="sans-serif"
          text-anchor="end"
          v-for="(tick, index) in yTicks"
          :key="index"
          :transform="`translate(0, ${y(tick) + 0.5})`"
        >
          <line stroke="currentColor" x2="-6"></line>
          <text fill="currentColor" x="-9" dy="0.32em">{{ tick }}</text>
        </g>
      </g>
      <!-- bars -->
      <g class="bars" fill="none">
        <rect 
          v-for="(bar, index) in bars"
          :fill="color(bar.xLabel)"
          :key="index"
          :height="bar.height"
          :width="bar.width"
          :x="bar.x"
          :y="bar.y"
        ></rect>
      </g>
    </g>
  </svg>
</template>
<script>
import * as d3 from "d3";
import { scaleBand, scaleLinear } from 'd3-scale'
export default { 
  props: {
    dataset: {
      type: Array
    }, 
    colors: {
      type: Array
    }
  },
  data() {
    return {
      height: 240,
      width: 650
    }
  },
  computed: {
    color() {
      return d3.scaleOrdinal().range(this.colors); 
    },
    yTicks() {
      return this.y.ticks(5);
    },
    x() {
      return scaleBand()
      .range([0, this.width])
      .padding(0.3)
      .domain(this.dataset.map(e => e[0]));
    },

    y() {
      let values = this.dataset.map(e => e[1]);
      return scaleLinear()
      .range([this.height, 0])
      .domain([0, Math.max(...values)]);
    },
    bars() {
      let bars = this.dataset.map(d => {
        return {
          xLabel: d[0],
          x: this.x(d[0]),
          y: this.y(d[1]),
          width: this.x.bandwidth(),
          height: this.height - this.y(d[1])
        };
      });
      return bars;
    }
  },
}
</script>

Should it be in a watch?? Or maybe somewhere in computed?? Or should I create a method to call the transitions?

Related