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)
- which uses the Composition API get the current width / height of an element (with the help of the
Vue or D3: Who renders our SVG?
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
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>
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>
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;
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!
Top comments (3)
Hi, just wanted to say thanks for your code, it really helped me getting started!
Commenting for idiots like me...
Make sure that the
d3.select(...)
statement is put INSIDE onMountedReally nice and detailed example! Thanks Murat!