Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create a d3 force layout graph using React

I would like to create a d3 force layout graph using ReactJS.

I've created other graphs using React + d3 such as pie charts, line graphs, histograms. Now I wonder how to build a svg graphic like the d3 force layout which involves physics and user interaction.

Here is an example of what I want to build http://bl.ocks.org/mbostock/4062045

like image 782
cuadraman Avatar asked Dec 14 '22 14:12

cuadraman


2 Answers

Since D3 and React haven't decreased in popularity the last three years, I figured a more concrete answer might help someone here who wants to make a D3 force layout in React.

Creating a D3 graph can be exactly the same as for any other D3 graph. But you can also use React to replace D3's enter, update and exit functions. So React takes care of rendering the lines, circles and svg.

This could be helpfull when a user should be able to interact a lot with the graph. Where it would be possible for a user to add, delete, edit and do a bunch of other stuff to the nodes and links of the graph.

There are 3 components in the example below. The App component holds the app's state. In particular the 2 standard arrays with node and link data that should be passed to D3's d3.forceSimulation function.

Then there's one component for the links and one component for the nodes. You can use React to do anything you want with the lines and circles. You could use React's onClick, for example.

The functions enterNode(selection) and enterLink(selection) render the lines and circles. These functions are called from within the Node and Link components. These components take the nodes' and links' data as prop before they pass it to these enter functions.

The functions updateNode(selection) and updateLink(selection) update the nodes' and links' positions. They are called from D3's tick function.

I used these functions from a React + D3 force layout example from Shirley Wu.

It's only possible to add nodes in the example below. But I hope it shows how to make the force layout more interactive using React.

Codepen live example

///////////////////////////////////////////////////////////
/////// Functions and variables
///////////////////////////////////////////////////////////

var FORCE = (function(nsp) {

  var
    width = 1080,
    height = 250,
    color = d3.scaleOrdinal(d3.schemeCategory10),

    initForce = (nodes, links) => {
      nsp.force = d3.forceSimulation(nodes)
        .force("charge", d3.forceManyBody().strength(-200))
        .force("link", d3.forceLink(links).distance(70))
        .force("center", d3.forceCenter().x(nsp.width / 2).y(nsp.height / 2))
        .force("collide", d3.forceCollide([5]).iterations([5]));
    },

    enterNode = (selection) => {
      var circle = selection.select('circle')
        .attr("r", 25)
        .style("fill", function (d) {
            if (d.id > 3) {
                return 'darkcyan'
            } else { return 'tomato' }})
        .style("stroke", "bisque")
        .style("stroke-width", "3px")

      selection.select('text')
        .style("fill", "honeydew")
        .style("font-weight", "600")
        .style("text-transform", "uppercase")
        .style("text-anchor", "middle")
        .style("alignment-baseline", "middle")
        .style("font-size", "10px")
        .style("font-family", "cursive")
    },

    updateNode = (selection) => {
      selection
        .attr("transform", (d) => "translate(" + d.x + "," + d.y + ")")
        .attr("cx", function(d) {
          return d.x = Math.max(30, Math.min(width - 30, d.x));
        })
        .attr("cy", function(d) {
          return d.y = Math.max(30, Math.min(height - 30, d.y));
        })
    },

    enterLink = (selection) => {
      selection
        .attr("stroke-width", 3)
        .attr("stroke", "bisque")
    },

    updateLink = (selection) => {
      selection
        .attr("x1", (d) => d.source.x)
        .attr("y1", (d) => d.source.y)
        .attr("x2", (d) => d.target.x)
        .attr("y2", (d) => d.target.y);
    },

    updateGraph = (selection) => {
      selection.selectAll('.node')
        .call(updateNode)
      selection.selectAll('.link')
        .call(updateLink);
    },

    dragStarted = (d) => {
      if (!d3.event.active) nsp.force.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y
    },

    dragging = (d) => {
      d.fx = d3.event.x;
      d.fy = d3.event.y
    },

    dragEnded = (d) => {
      if (!d3.event.active) nsp.force.alphaTarget(0);
      d.fx = null;
      d.fy = null
    },

    drag = () => d3.selectAll('g.node')
    .call(d3.drag()
      .on("start", dragStarted)
      .on("drag", dragging)
      .on("end", dragEnded)
    ),

    tick = (that) => {
      that.d3Graph = d3.select(ReactDOM.findDOMNode(that));
      nsp.force.on('tick', () => {
        that.d3Graph.call(updateGraph)
      });
    };

  nsp.width = width;
  nsp.height = height;
  nsp.enterNode = enterNode;
  nsp.updateNode = updateNode;
  nsp.enterLink = enterLink;
  nsp.updateLink = updateLink;
  nsp.updateGraph = updateGraph;
  nsp.initForce = initForce;
  nsp.dragStarted = dragStarted;
  nsp.dragging = dragging;
  nsp.dragEnded = dragEnded;
  nsp.drag = drag;
  nsp.tick = tick;

  return nsp

})(FORCE || {})

////////////////////////////////////////////////////////////////////////////
/////// class App is the parent component of Link and Node
////////////////////////////////////////////////////////////////////////////

class App extends React.Component {
    constructor(props) {
      super(props)
      this.state = {
        addLinkArray: [],
        name: "",
        nodes: [{
            "name": "fruit",
            "id": 0
          },
          {
            "name": "apple",
            "id": 1
          },
          {
            "name": "orange",
            "id": 2
          },
          {
            "name": "banana",
            "id": 3
          }
        ],
        links: [{
            "source": 0,
            "target": 1,
            "id": 0
          },
          {
            "source": 0,
            "target": 2,
            "id": 1
          },
          {
            "source": 0,
            "target": 3,
            "id": 2
          }
        ]
      }
      this.handleAddNode = this.handleAddNode.bind(this)
      this.addNode = this.addNode.bind(this)
    }

    componentDidMount() {
      const data = this.state;
      FORCE.initForce(data.nodes, data.links)
      FORCE.tick(this)
      FORCE.drag()
    }

    componentDidUpdate(prevProps, prevState) {
      if (prevState.nodes !== this.state.nodes || prevState.links !== this.state.links) {
        const data = this.state;
        FORCE.initForce(data.nodes, data.links)
        FORCE.tick(this)
        FORCE.drag()
      }
    }

    handleAddNode(e) {
      this.setState({
        [e.target.name]: e.target.value
      });
    }

    addNode(e) {
      e.preventDefault();
      this.setState(prevState => ({
        nodes: [...prevState.nodes, {
          name: this.state.name,
          id: prevState.nodes.length + 1,
          x: FORCE.width / 2,
          y: FORCE.height / 2
        }],
        name: ''
      }));
    }

    render() {
        var links = this.state.links.map((link) => {
            return ( <
              Link key = {
                link.id
              }
              data = {
                link
              }
              />);
            });
          var nodes = this.state.nodes.map((node) => {
              return ( <
                Node data = {
                  node
                }
                name = {
                  node.name
                }
                key = {
                  node.id
                }
                />);
              });
            return ( <
              div className = "graph__container" >
              <
              form className = "form-addSystem"
              onSubmit = {
                this.addNode.bind(this)
              } >
              <
              h4 className = "form-addSystem__header" > New Node < /h4> <
              div className = "form-addSystem__group" >
              <
              input value = {
                this.state.name
              }
              onChange = {
                this.handleAddNode.bind(this)
              }
              name = "name"
              className = "form-addSystem__input"
              id = "name"
              placeholder = "Name" / >
              <
              label className = "form-addSystem__label"
              htmlFor = "title" > Name < /label> < /
              div > <
              div className = "form-addSystem__group" >
              <
              input className = "btnn"
              type = "submit"
              value = "add node" / >
              <
              /div> < /
              form > <
              svg className = "graph"
              width = {
                FORCE.width
              }
              height = {
                FORCE.height
              } >
              <
              g > {
                links
              } <
              /g> <
              g > {
                nodes
              } <
              /g> < /
              svg > <
              /div>
            );
          }
        }

        ///////////////////////////////////////////////////////////
        /////// Link component
        ///////////////////////////////////////////////////////////

        class Link extends React.Component {

          componentDidMount() {
            this.d3Link = d3.select(ReactDOM.findDOMNode(this))
              .datum(this.props.data)
              .call(FORCE.enterLink);
          }

          componentDidUpdate() {
            this.d3Link.datum(this.props.data)
              .call(FORCE.updateLink);
          }

          render() {
            return ( <
              line className = 'link' / >
            );
          }
        }

        ///////////////////////////////////////////////////////////
        /////// Node component
        ///////////////////////////////////////////////////////////

        class Node extends React.Component {

          componentDidMount() {
            this.d3Node = d3.select(ReactDOM.findDOMNode(this))
              .datum(this.props.data)
              .call(FORCE.enterNode)
          }

          componentDidUpdate() {
            this.d3Node.datum(this.props.data)
              .call(FORCE.updateNode)
          }

          render() {
            return ( <
              g className = 'node' >
              <
              circle onClick = {
                this.props.addLink
              }
              /> <
              text > {
                this.props.data.name
              } < /text> < /
              g >
            );
          }
        }

        ReactDOM.render( < App / > , document.querySelector('#root'))
.graph__container {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

.graph {
  background-color: steelblue;
}

.form-addSystem {
  display: grid;
  grid-template-columns: min-content min-content;
  background-color: aliceblue;
  padding-bottom: 15px;
  margin-right: 10px;
}

.form-addSystem__header {
  grid-column: 1/-1;
  text-align: center;
  margin: 1rem;
  padding-bottom: 1rem;
  text-transform: uppercase;
  text-decoration: none;
  font-size: 1.2rem;
  color: steelblue;
  border-bottom: 1px dotted steelblue;
  font-family: cursive;
}

.form-addSystem__group {
  display: grid;
  margin: 0 1rem;
  align-content: center;
}

.form-addSystem__input,
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
  outline: none;
  border: none;
  border-bottom: 3px solid teal;
  padding: 1.5rem 2rem;
  border-radius: 3px;
  background-color: transparent;
  color: steelblue;
  transition: all .3s;
  font-family: cursive;
  transition: background-color 5000s ease-in-out 0s;
}

.form-addSystem__input:focus {
  outline: none;
  background-color: platinum;
  border-bottom: none;
}

.form-addSystem__input:focus:invalid {
  border-bottom: 3px solid steelblue;
}

.form-addSystem__input::-webkit-input-placeholder {
  color: steelblue;
}

.btnn {
  text-transform: uppercase;
  text-decoration: none;
  border-radius: 10rem;
  position: relative;
  font-size: 12px;
  height: 30px;
  align-self: center;
  background-color: cadetblue;
  border: none;
  color: aliceblue;
  transition: all .2s;
}

.btnn:hover {
  transform: translateY(-3px);
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .2)
}

.btnn:hover::after {
  transform: scaleX(1.4) scaleY(1.6);
  opacity: 0;
}

.btnn:active,
.btnn:focus {
  transform: translateY(-1px);
  box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .2);
  outline: 0;
}

.form-addSystem__label {
  color: lightgray;
  font-size: 20px;
  font-family: cursive;
  font-weight: 700;
  margin-left: 1.5rem;
  margin-top: .7rem;
  display: block;
  transition: all .3s;
}

.form-addSystem__input:placeholder-shown+.form-addSystem__label {
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4rem);
}

.form-addSystem__link {
  grid-column: 2/4;
  justify-self: center;
  align-self: center;
  text-transform: uppercase;
  text-decoration: none;
  font-size: 1.2rem;
  color: steelblue;
}
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
</script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.js"></script>

<div id="root"></div>
like image 101
Vincent Avatar answered Dec 28 '22 05:12

Vincent


Colin Megill has a great blog post on this: http://formidable.com/blog/2015/05/21/react-d3-layouts/. There is also a working jsbin http://jsbin.com/fanofa/14/embed?js,output. There is a b.locks.org account, JMStewart, who has an interesting implementation that wraps React in d3 code: http://bl.ocks.org/JMStewart/f0dc27409658ab04d1c8.

Everyone who implements force-layouts in React notices a minor performance loss. For complex charts (beyond 100 nodes) this becomes much more severe.

Note: There is an open issue on react-motion for applying forces (which would otherwise be a good react solution to this) but its gone silent.

like image 28
Ian Delairre Avatar answered Dec 28 '22 03:12

Ian Delairre