Search code examples
domd3.jssvgvue.jsvuejs2

Draw d3-axis without direct DOM manipulation


Is there a way to use d3-axis without directly manipulating the DOM? D3.js complements Vue.js nicely when used for its helper functions (especially d3-scale).

I'm currently using a simple Vue template to generate a SVG. To generate the axis, I am first creating a <g ref="axisX"> element and then calling d3.select(this.$refs.axisX).call(d3.axisBottom(this.scale.x)).

I would like to avoid using d3.select to prevent direct DOM modifications. It works well but it conflicts with Vue.js' scoped styles.

Is there a way to access d3-axis without calling it from a DOM element? It would be useful to have access to its path generation function independently instead of via the DOM.

Here is a sample CodePen: https://codepen.io/thibautg/pen/BYRBXW


Solution

  • This is a situation that calls for a custom directive. Custom directives allow you to manipulate the DOM within the element they are attached to.

    In this case, I created a directive that takes an argument for which axis and a value which is your scale computed. Based on whether the axis is x or y, it calls axisBottom or axisLeft with scale[axis].

    No more watching. The directive will be called any time anything updates. You could put in a check to see whether scale in particular had changed from its previous value, if you wanted.

    new Vue({
      el: "#app",
      data() {
        return {
          width: 600,
          height: 400,
          margin: {
            top: 20,
            right: 20,
            bottom: 20,
            left: 20
          },
          items: [
            { name: "a", val: 10 },
            { name: "b", val: 8 },
            { name: "c", val: 1 },
            { name: "d", val: 5 },
            { name: "e", val: 6 },
            { name: "f", val: 3 }
          ]
        };
      },
      computed: {
        outsideWidth() {
          return this.width + this.margin.left + this.margin.right;
        },
        outsideHeight() {
          return this.height + this.margin.top + this.margin.bottom;
        },
        scale() {
          const x = d3
            .scaleBand()
            .domain(this.items.map(x => x.name))
            .rangeRound([0, this.width])
            .padding(0.15);
          const y = d3
            .scaleLinear()
            .domain([0, Math.max(...this.items.map(x => x.val))])
            .rangeRound([this.height, 0]);
          return { x, y };
        }
      },
      directives: {
        axis(el, binding) {
          const axis = binding.arg;
          const axisMethod = { x: "axisBottom", y: "axisLeft" }[axis];
          const methodArg = binding.value[axis];
    
          d3.select(el).call(d3[axisMethod](methodArg));
        }
      }
    });
    rect.bar {
      fill: steelblue;
    }
    <script src="//unpkg.com/vue@2"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/d3/4.11.0/d3.min.js"></script>
    <div id="app">
      <svg :width="outsideWidth"
           :height="outsideHeight">
        <g :transform="`translate(${margin.left},${margin.top})`">
          <g class="bars">
            <template v-for="item in items">
              <rect class="bar"
                    :x="scale.x(item.name)"
                    :y="scale.y(item.val)"
                    :width="scale.x.bandwidth()"
                    :height="height - scale.y(item.val)"
                    />
            </template>
            <g v-axis:x="scale" :transform="`translate(0,${height})`"></g>
            <g v-axis:y="scale"></g>
          </g>
        </g>
      </svg>
    </div>