miaoyu

Building D3 Components with React – Hacker Noon

miaoyu · 2017-06-14推荐 · 179阅读 CET/4 150 CET/6 6 原文链接

D3 is a powerful library for creating visualizations with JavaScript. While it allows a high-level of customizations, it can be challenging to create isolated, declarative components. Thankfully, this is something React does really well. While there are libraries for integrating the two, I’ve found creating a custom integration to work best for my purposes. We’ll walk through a basic example of how this integration works as well as some lessons learned along the way.

Up & Running

NOTE: While we’ll be walking through the details of this implementation, the following assumes you have some basic knowledge of React (specifically the component lifecycle) and some knowledge of D3. It’s also important to note that we’re using D3 4.x, which is a little different than 3.x.

We’ll be building basic progress arc. We’ll be using the [create-react-app](https://facebook.github.io/react/blog/2016/07/22/create-apps-with-no-configuration.html) CLI, but I’ve added an example repo here. If you don’t have create-react-app already, you’ll need to install it with npm install -g create-react-app.

create-react-app react-d3-example
cd react-d3-example
npm install --save d3

Now run npm start. If you see this in the browser, you’re good to go!

Setting our Context

First let’s add our D3 component:

touch src/ProgressArc.js

This is what it will look like:

import React, { Component } from 'react';
import * as d3 from "d3";
class ProgressArc extends Component {
  render() {
    return (
      <div ref="arc"></div>
    )
  }
}
export default ProgressArc;

Let’s empty the contents of src/App.js and call our ProgressArc.

import React, { Component } from 'react';
import ProgressArc from './ProgressArc';
class App extends Component {
  render() {
    return (
      <ProgressArc />
    );
  }
}
export default App;

Great! Now we’ll add our SVG context to src/ProgressArc.js.

...
class ProgressArc extends Component {
  componentDidMount() {
    this.setContext();
  }
  setContext() {
    return d3.select(this.refs.arc).append('svg')
      .attr('height', '300px')
      .attr('width', '300px')
      .attr('id', 'd3-arc')
      .append('g')
      .attr('transform', `translate(150, 150)`);
  }
...

The context here is the SVG canvas where we’ll be drawing our visualization. When the component mounts, we’re appending a SVG, setting the height and width, and centering the content. Eventually we’ll make height, width, and id dynamic props to be passed into the component.

Setting our Background

...
class ProgressArc extends Component {
  componentDidMount() {
    const context = this.setContext();
    this.setBackground(context);
  }
  ...
  setBackground(context) {
    return context.append('path')
    .datum({ endAngle: this.tau })
    .style('fill', '#e6e6e6')
    .attr('d', this.arc());
  }
  tau = Math.PI * 2;
  arc() {
    return d3.arc()
      .innerRadius(100)
      .outerRadius(110)
      .startAngle(0)
  }

  ...

There’s quite a bit going on here, so let’s break it down. After we create our context, we’re appending out background on top of it. Specifically, we’re appending a path which we can shape and style. The datum attribute is telling this path where to end, which we’re defining as tau.

NOTE: We’re defining _tau_ as 2π here. D3 uses radians to measure arc length, which is fine, except most datasets don’t come in terms of radians, and to keep things simple, we’ll use _tau_ as a way to quickly convert radians to percent. All you really need to know about this is that the circumference of any circle is equal to 2 x π. We can then multiply _tau_ by a percentage and get a visualization that matches our expectations. (If I pass in 0.50, I should expect to see the path be a semi-circle.)

Since we want our background to be a full circle, we’ll set it to tau and fill it with a light gray. This path will follow the path we are defining in this.arc().

this.arc() is returning a D3 arc that we are telling to start at 0 (the top, center of the circle) and has an inner-radius of 100px and an outer-radius or 110px. All of these properties will be things we can set as dynamic properties later when we call the component. If everything went well, you should see something like this when the view re-renders.

Setting Our Foreground

Now that we’ve created our background, we can add our foreground, which will display the percentage of our progress. Similar to setBackground(), we’ll call the function in componentDidMount() and write the function below.

...
componentDidMount() {
  const context = this.setContext();
  this.setBackground(context);
  this.setForeground(context);
}
...
setForeground(context) {
  return context.append('path')
    .datum({ endAngle: this.tau * 0.3 })
    .style('fill', '#00ff00')
    .attr('d', this.arc());
}

We’re setting the end angle for the foreground path to be 30% of the circumference and have a green fill. If everything went well, it should look like this:

Adding Dynamic Props

Now that we have the basic setup, let’s refactor a bit to make these properties dynamic. First we’ll declare our props in src/App.js.

...
class App extends Component {
  render() {
    return (
      <ProgressArc
        height={300}
        width={300}
        innerRadius={100}
        outerRadius={110}
        id="d3-arc"
        backgroundColor="#e6e6e6"
        foregroundColor="#00ff00"
        percentComplete={0.3}
      />
    );
  }
}
...

Let’s update ProgressArc to use props. We’ll also add propTypes and displayName as a general best practice.

...
class ProgressArc extends Component {
  displayName: 'ProgressArc';
  propTypes: {
    id: PropTypes.string,
    height: PropTypes.number,
    width: PropTypes.number,
    innerRadius: PropTypes.number,
    outerRadius: PropTypes.number,
    backgroundColor: PropTypes.string,
    foregroundColor: PropTypes.string,
    percentComplete: PropTypes.number
  }
  componentDidMount() {
    const context = this.setContext();
    this.setBackground(context);
    this.setForeground(context);
  }
  setContext() {
    const { height, width, id} = this.props;
    return d3.select(this.refs.arc).append('svg')
      .attr('height', height)
      .attr('width', width)
      .attr('id', id)
      .append('g')
      .attr('transform', `translate(${height / 2}, ${width / 2})`);
  }
  setBackground(context) {
    return context.append('path')
    .datum({ endAngle: this.tau })
    .style('fill', this.props.backgroundColor)
    .attr('d', this.arc());
  }
  setForeground(context) {
    return context.append('path')
    .datum({ endAngle: this.tau * this.props.percentComplete })
    .style('fill', this.props.foregroundColor)
    .attr('d', this.arc());
  }
  tau = Math.PI * 2;
  arc() {
    return d3.arc()
      .innerRadius(this.props.innerRadius)
      .outerRadius(this.props.outerRadius)
      .startAngle(0)
  }
...

Great! We’ve created a declarative, isolated component for D3! We (or another developer) could easily call this component somewhere else, pass in the desired properties, and have confidence it would work consistently. But there is another aspect we should tie up first. We should also create a way for this component to respond to updates.

Adding Updates

React provides a powerful API for updating components to reflect state changes via the VirtualDOM. Let’s add a toggle button to update the ProgressArc percentComplete prop in src/App.js.

class App extends Component {
  constructor(props) {
     super(props);
     this.state = {percentComplete: 0.3};
     this.togglePercent = this.togglePercent.bind(this);
   }
  togglePercent() {
    const percentage = this.state.percentComplete === 0.3 ? 0.7 : 0.3;
    this.setState({percentComplete: percentage});
  }
  render() {
  console.log(this.state.percentComplete);  
  return (
      <div>
        <a onClick={this.togglePercent}>Toggle Arc</a>
        <ProgressArc
          ...
          percentComplete={this.state.percentComplete}
        />
      </div>
    );
  }
}

There’s a bit going on here. We’ve added a toggle button that calls togglePercent() to toggle the state from 0.3 to 0.7 percent when clicked. We’ve also added an ES6 constructor to set the initial state for the App component and a console.log() in render() as a sanity check to ensure the state is updating. However, after the view re-renders, you may notice an issue when the button is clicked. While we can see the state update in the JS console, the ProgressArc does not. Why?

This is where we get into some lessons learned. While React is updating the state and ProgressArc component as we would expect, the SVG does not reflect that change. This is because SVG’s don’t respond to updates. So we have to remove the initial SVG and re-draw a new one. To do this, we’ll need to modify how the ProgressArc handles updates.

First we’re going to roll up all of our functionality in componentDidMount() into a function called drawArc().

class ProgressArc extends Component {
...
componentDidMount() {
  this.drawArc();
}
drawArc() {
  const context = this.setContext();
  this.setBackground(context);
  this.setForeground(context);
}
...

Now we’ll add the functions componentDidUpdate() and redrawArc() to handle updates to the percentComplete prop.

  ...
  componentDidUpdate() {
    this.redrawArc();
  }
  drawArc() {
    ...
  }
  redrawArc() {
    const context = d3.select(`#${this.props.id}`);
    context.remove();
    this.drawArc();
  }
  ...

We’re selecting the SVG context by the id prop we set earlier, removing it from the DOM, and calling drawArc() to redraw with the new data available. Thankfully, D3 does this so quickly, it will look like the same context is simply updating based on the new data. If all is well, we should see something like this:

Cool! Now we have an isolated, declarative D3 component that responds to updates. We’ve abstracted the internals of how D3 works and created a visualization that can easily be used by anyone!

A Step Further: Adding Animation

We can also take this a step further if we want. Animation is one of the things D3 does really well, but the details can be a bit challenging at first. We can abstract these away in our component and provide an easy way for anyone to reuse our work.

We’ll start by altering the setForeground() function.

setForeground(context) {
    return context.append('path')
    .datum({ endAngle: 0 }) // <- (instead of tau * our percentage)
    .style('fill', this.props.foregroundColor)
    .attr('d', this.arc());
  }

Initially we want our endAngle property of the foreground path to be at 0 and transition to the final percentage. Next we’ll modify our drawArc() function. We’re going to add updatePercent() to our function list, which we’ll define below.

...
drawArc() {
  ...
  this.updatePercent(context);
}
...
updatePercent(context) {
  return this.setForeground(context).transition()
   .duration(this.props.duration)
   .call(this.arcTween, this.tau * this.props.percentComplete, this.arc());
}
...

We’re calling setForeground() as we did before with our endAngle being set to tau * percent, but we’re adding a transition over a duration (based in milliseconds, which we’re passing in as a prop). We’re then calling another function, arcTween(), which we’ll define below.

arcTween(transition, newAngle, arc) {
  transition.attrTween('d', (d) => {
   const interpolate = d3.interpolate(d.endAngle, newAngle);
   const newArc = d;
   return (t) => {
     newArc.endAngle = interpolate(t);
     return arc(newArc);
   };
  });
}

arcTween() is using D3’s interpolate function under the hood to return a new D3 arc. After we add the duration prop to ProgressArc in App.js, we should see the animation working properly as we click the toggle button.

...
<ProgressArc
  ...
  duration={2000}
  ...
/>
...

If everything went well, you should have something that looks like this:

Conclusion

I prefer this implementation to using an external lib, because it allows me to precisely define the details of the visualization, and I’m not stuck fighting built-in styles or loading assets I don’t need. While it’s a little more work up front, the result is a lightweight solution that delivers exactly what I want.

Abstracting the implementation details of D3 into React components and using the component lifecycle allows us to create custom visualizations that are easily reusable throughout our application. Hope this was helpful! Thanks for reading!

相关文章