DEV Community

loading...
Cover image for Using Vue 3's Composition API with D3

Using Vue 3's Composition API with D3

Murat Kemaldar
UI Engineer / Frontend Dev with a design background.
Updated on ・5 min read

Vue had a major update in 2020 to version 3, which includes the new Composition API.

In a nutshell, the Composition API is all about enabling better code re-use, by exposing Vue's internal bits and pieces, which you usually define as an object in a component (like lifecycle hooks, computed properties, watchers...).

If you have worked with Vue 2 before, you can compare the Composition API to Mixins, but better. Anthony Gore explains that perfectly.

D3 is JavaScript data visualization library best used for custom chart components. It has also changed quite a bit. It introduced a new Join API, which makes the API much more accessible. There hasn't been a better time to learn D3.

What to expect

In this article, I will be showing an annotated example to render a responsive line chart component. This example has 3 main files where the action is happening:

  • App.vue component
    • which has some data and 2 buttons to manipulate the data
    • which renders a ResponsiveLineChart component with that data
  • ResponsiveLineChart.vue component
    • which uses the Composition API to render an SVG with D3
    • which updates when the underlying data or the width / height of our SVG changes
  • resizeObserver.js custom hook
    • which uses the Composition API get the current width / height of an element (with the help of the ResizeObserver API, which means width / height will update on resize)

Vue or D3: Who renders our SVG?

Vue or D3

Both Vue and D3 have their own way of handling the DOM.

In the following example, Vue will render the SVG itself as a container but we will let D3 handle what is happening inside of our SVG (with the so-called General Update Pattern of D3.

The main reason for this is to help you to understand most of the other D3 examples out there which all use the "D3 way" of manipulating the DOM. It is a bit more verbose and imperative, but gives you more flexibility and control when it comes to animations, or handling "new", "updating" or "removing" elements. You are free to let Vue handle all the rendering to be more declarative, but you don't have to. It's a trade-off!

Same decision was also made in my other series where we combine React Hooks and D3.

This following example was made with @vue/cli and d3. You can check out the full example here on my GitHub repo.

Here is also a working Demo.

The example

Let's go!

App.vue

<template>
  <div id="app">
    <h1>Using Vue 3 (Composition API) with D3</h1>
    <responsive-line-chart :data="data" />
    <div class="buttons">
      <button @click="addData">Add data</button>
      <button @click="filterData">Filter data</button>
    </div>
  </div>
</template>

<script>
import ResponsiveLineChart from "./components/ResponsiveLineChart.vue";

export default {
  name: "App",
  components: {
    ResponsiveLineChart,
  },
  data() {
    return {
      data: [10, 40, 15, 25, 50],
    };
  },
  methods: {
    addData() {
      // add random value from 0 to 50 to array
      this.data = [...this.data, Math.round(Math.random() * 50)];
    },
    filterData() {
      this.data = this.data.filter((v) => v <= 35);
    },
  },
};
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  max-width: 720px;
  margin: 100px auto;
  padding: 0 20px;
}

svg {
  /* important for responsiveness */
  display: block;
  fill: none;
  stroke: none;
  width: 100%;
  height: 100%;
  overflow: visible;
  background: #eee;
}

.buttons {
  margin-top: 2rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

ResponsiveLineChart.vue

<template>
  <div ref="resizeRef">
    <svg ref="svgRef">
      <g class="x-axis" />
      <g class="y-axis" />
    </svg>
  </div>
</template>

<script>
import { onMounted, ref, watchEffect } from "vue";
import {
  select,
  line,
  scaleLinear,
  min,
  max,
  curveBasis,
  axisBottom,
  axisLeft,
} from "d3";
import useResizeObserver from "@/use/resizeObserver";

export default {
  name: "ResponsiveLineChart",
  props: ["data"],
  setup(props) {
    // create ref to pass to D3 for DOM manipulation
    const svgRef = ref(null);

    // this creates another ref to observe resizing, 
    // which we will attach to a DIV,
    // since observing SVGs with the ResizeObserver API doesn't work properly
    const { resizeRef, resizeState } = useResizeObserver();

    onMounted(() => {
      // pass ref with DOM element to D3, when mounted (DOM available)
      const svg = select(svgRef.value);

      // whenever any dependencies (like data, resizeState) change, call this!
      watchEffect(() => {
        const { width, height } = resizeState.dimensions;

        // scales: map index / data values to pixel values on x-axis / y-axis
        const xScale = scaleLinear()
          .domain([0, props.data.length - 1]) // input values...
          .range([0, width]); // ... output values

        const yScale = scaleLinear()
          .domain([min(props.data), max(props.data)]) // input values...
          .range([height, 0]); // ... output values

        // line generator: D3 method to transform an array of values to data points ("d") for a path element
        const lineGen = line()
          .curve(curveBasis)
          .x((value, index) => xScale(index))
          .y((value) => yScale(value));

        // render path element with D3's General Update Pattern
        svg
          .selectAll(".line") // get all "existing" lines in svg
          .data([props.data]) // sync them with our data
          .join("path") // create a new "path" for new pieces of data (if needed)

          // everything after .join() is applied to every "new" and "existing" element
          .attr("class", "line") // attach class (important for updating)
          .attr("stroke", "green") // styling
          .attr("d", lineGen); // shape and form of our line!

        // render axes with help of scales
        // (we let Vue render our axis-containers and let D3 populate the elements inside it)
        const xAxis = axisBottom(xScale);
        svg
          .select(".x-axis")
          .style("transform", `translateY(${height}px)`) // position on the bottom
          .call(xAxis);

        const yAxis = axisLeft(yScale);
        svg.select(".y-axis").call(yAxis);
      });
    });

    // return refs to make them available in template
    return { svgRef, resizeRef };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

resizeObserver.js

import { ref, reactive, onMounted, onBeforeUnmount } from "vue";

export const useResizeObserver = () => {
  // create a new ref, 
  // which needs to be attached to an element in a template
  const resizeRef = ref();
  const resizeState = reactive({
    dimensions: {}
  });

  const observer = new ResizeObserver(entries => {
    // called initially and on resize
    entries.forEach(entry => {
      resizeState.dimensions = entry.contentRect;
    });
  });

  onMounted(() => {
    // set initial dimensions right before observing: Element.getBoundingClientRect()
    resizeState.dimensions = resizeRef.value.getBoundingClientRect();
    observer.observe(resizeRef.value);
  });

  onBeforeUnmount(() => {
    observer.unobserve(resizeRef.value);
  });

  // return to make them available to whoever consumes this hook
  return { resizeState, resizeRef };
};

export default useResizeObserver;

Enter fullscreen mode Exit fullscreen mode

Conclusion

That's it! Hope the annotations / the code is self-explanatory. Let me know in the comments, if you have any questions / feedback!

Like I said earlier, you can check out the full example here on my GitHub repo.

Enjoy!

Discussion (0)

Forem Open with the Forem app