D3 + Vue example

 from Red Blob Games
17 Oct 2018

There are many articles about using Vue and D3 together, but they usually tell you to write the SVG in Vue instead of using D3's SVG functions:

However, you can use D3 for the SVG, and Vue for the reactivity. I'm not using watch or template ref. It's based on my Vue + Canvas code[6], where I use computed for reactivity. As of 2018 I haven't seen this technique described anywhere.

Vue+d3 chart
dataset:
color:
radius:
margin top: bottom:
margin left: right:

The code is below (also here). The main idea is that there's a reusable Vue component that produces a <g> element. You pass a function to that component to perform the one-time setup, and then return a function that should be called on update.

source code
// example code is under the CC0 license - No Rights Reserved

/* This is a reusable component that produces a <g> element
   that lets d3 control the DOM inside of it. */
Vue.component('vue-d3', {
    props: ['draw'],
    template: `<g :data-dummy="update()"></g>`,
    data() {
        return {update: () => null};
    },
    mounted() {
        this.update = this.draw(d3.select(this.$el));
    },
});


function minmax(data) {
    return [d3.min(data), d3.max(data)];
}

/* This is a chart (maybe you'd make it a component);
   put the data you depend on in props and data and computed,
   and provide a method that will get passed to the <vue-d3> */
new Vue({
    el: "figure",
    data: {
        dataset: 'sine',
        fill: 'hsl(0,50%,50%)',
        radius: 3,
        outerWidth: 600,
        outerHeight: 300,
        margin: {left: 30, top: 10, right: 10, bottom: 20},
    },
    computed: {
        data() {
            switch(this.dataset) {
            case 'sine':
                return d3.range(0, 5, 0.05)
                         .map(x => [x, Math.sin(x)]);
            case 'parabola':
                return d3.range(-10, 10, 0.2)
                         .map(x => [x, x*x]);
            case 'hilly':
                return d3.range(-15, 15, 0.3)
                         .map(x => [x, Math.abs(Math.sin(x/4)
                                     + 0.5*Math.cos(x/2) 
                                     + 0.3*Math.sin(x))]);
            }
            return [];
        },
    },
    methods: {
        draw(parent) {
            // This function handles creation,
            console.log('draw init');
            const x = d3.scaleLinear();
            const y = d3.scaleLinear();
            const xAxis = d3.axisBottom(x).ticks(10);
            const yAxis = d3.axisLeft(y).ticks(10);

            const root = parent.append('g');
            const xAxisG = root.append('g');
            const yAxisG = root.append('g');
            const line = root.append('g');

            // and returns a function that handles updates
            return () => {
                console.log('draw update');

                root
                    .attr('transform',
                          `translate(${this.margin.left}, 
                                     ${this.margin.top})`);
                
                const innerWidth = (this.outerWidth
                                    - this.margin.left
                                    - this.margin.right),
                      innerHeight = (this.outerHeight
                                    - this.margin.top
                                    - this.margin.bottom);
                x
                    .domain(minmax(this.data.map(d => d[0])))
                    .range([0, innerWidth]);
                y
                    .domain(minmax(this.data.map(d => d[1])))
                    .range([innerHeight, 0]);
                xAxisG
                    .attr('transform', `translate(0,${y(0)})`)
                    .call(xAxis);
                yAxisG
                    .attr('transform', `translate(${x(0)},0)`)
                    .call(yAxis);

                let selection = line.selectAll('circle')
                                    .data(this.data);
                selection.exit().remove();
                selection.enter().append('circle')
                    .attr('stroke', "white")
                    .attr('stroke-width', "0.5")
                    .merge(selection)
                    .attr('r', this.radius)
                    .attr('fill', this.fill)
                    .transition()
                    .attr('cx', d => x(d[0]))
                    .attr('cy', d => y(d[1]));
            };
        }
    },
});

Look at the draw(parent). It's d3 code. It doesn't do any Vue-specific things. In theory, this will make it easier for you to reuse this code in another project that doesn't use Vue.

Motivation: I try to avoid watchers. There are two errors I make:

  1. (correctness) As I change the code, I might introduce a dependency that I forgot to watch. I change that value but the diagram doesn't update, even though it should.
  2. (performance) As I change the code, I might no longer have a dependency that I have been watching. I change that value and the diagram updates, even though it shouldn't.

By using Vue's automatic dependency tracking, these dependencies are always accurate, and I avoid both of these issues.

Caveats: the code assumes that the element is created only once. This is probably fine for most cases, but you can't turn it on and off with v-if, or add a variable number of them with v-for, etc. Maybe :key would help; I'm not sure.

TODO: I need to write a better example. Above, one function does all the drawing. But the same technique should work when you have different parts of the visualization, each depending on some subset of data, and then only those parts will redraw. For example, if I have a line that depends on x,y and a horizontal axis that depends on x and a vertical axis that depends on y, then when I change x, it will redraw the line and horizontal axis but not the vertical axis. Each would be a separate <vue-d3> component with its own draw function passed in.

I haven't used this in a real project and there may be more caveats.

Email me , or tweet @redblobgames, or comment: