D3 + Vue example

from Red Blob Games
17 Oct 2018

There are several ways to use Vue and D3 together. Here’s an attempt to use D3 for the DOM and Vue for the reactivity. I think it’s a little different from other techniques I’ve seen. I’m using no watchers and I’m using no refs. It’s based on my Vue + Canvas code.

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.

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

Email me at , or tweet to @redblobgames, or post a public comment: