block by sxywu 1db896c1a38d89ae71b4

The Force with React + D3, Approach #2

Full Screen

React + D3 exploration with the force layout:

Pro:

Con:

Use case: React app with a small but highly interactive visualization


The original using only D3: Enter-Update-Exit in Force Layout

The Force with React + D3, Approach #1

The Force with React + D3, Approach #2

The Force with React + D3, Approach #3

index.html

<!DOCTYPE html>
<meta charset="utf-8">
<head>
  <script src="https://fb.me/react-0.14.3.js"></script>
  <script src="https://fb.me/react-dom-0.14.3.js"></script>
  <script src="https://npmcdn.com/babel-core@5.8.34/browser.min.js"></script>
  <script src="//d3js.org/d3.v3.min.js"></script>
  <script src="//underscorejs.org/underscore-min.js"></script>
  <script src="generate_data.js"></script>

  <link rel="stylesheet" href="example.css" type="text/css" />
</head>

<body>

<div id="main" />

<script type="text/babel">

var width = 960;
var height = 500;
var force = d3.layout.force()
  .charge(-300)
  .linkDistance(50)
  .size([width, height]);

// *****************************************************
// ** d3 functions to manipulate attributes
// *****************************************************

var enterNode = (selection) => {
  selection.select('circle')
    .attr("r", (d) => d.size)
    .call(force.drag);

  selection.select('text')
    .attr("x", (d) => d.size + 5)
    .attr("dy", ".35em");
};

var updateNode = (selection) => {
  selection.attr("transform", (d) => "translate(" + d.x + "," + d.y + ")");
};

var enterLink = (selection) => {
  selection.attr("stroke-width", (d) => d.size);
};

var 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);
};

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

// *****************************************************
// ** React classes to enter/exit elements
// *****************************************************

var Node = React.createClass({
  componentDidMount() {
    this.d3Node = d3.select(ReactDOM.findDOMNode(this))
      .datum(this.props.data)
      .call(enterNode);
  },

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

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

var Link = React.createClass({
  componentDidMount() {
    this.d3Link = d3.select(ReactDOM.findDOMNode(this))
      .datum(this.props.data)
      .call(enterLink);
  },

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

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

// *****************************************************
// ** Graph and App components
// *****************************************************

var Graph = React.createClass({
  componentDidMount() {
    this.d3Graph = d3.select(ReactDOM.findDOMNode(this));
    force.on('tick', () => {
      // after force calculation starts, call updateGraph
      // which uses d3 to manipulate the attributes,
      // and React doesn't have to go through lifecycle on each tick
      this.d3Graph.call(updateGraph);
    });
  },

  componentDidUpdate() {
    // we should actually clone the nodes and links
    // since we're not supposed to directly mutate
    // props passed in from parent, and d3's force function
    // mutates the nodes and links array directly
    // we're bypassing that here for sake of brevity in example
    force.nodes(this.props.nodes).links(this.props.links);
    
    // start force calculations after
    // React has taken care of enter/exit of elements
    force.start();
  },

  render() {
    // use React to draw all the nodes, d3 calculates the x and y
    var nodes = _.map(this.props.nodes, (node) => {
      return (<Node data={node} key={node.key} />);
    });
    var links = _.map(this.props.links, (link) => {
      return (<Link key={link.key} data={link} />);
    });

    return (
      <svg width={width} height={height}>
        <g>
          {links}
          {nodes}
        </g>
      </svg>
    );
  }
});

var App = React.createClass({
  getInitialState() {
    return {
      nodes: [],
      links: [],
    };
  },

  componentDidMount() {
    this.updateData();
  },

  updateData() {
    // randomData is loaded in from external file generate_data.js
    // and returns an object with nodes and links
    var newState = randomData(this.state.nodes, width, height);
    this.setState(newState);
  },

  render() {
    return (
      <div>
        <div className="update" onClick={this.updateData}>update</div>
        <Graph nodes={this.state.nodes} links={this.state.links} />
      </div>
    );
  },
});

ReactDOM.render(
  <App />,
  document.getElementById('main') 
);

</script>

</body>

example.css

body {
  font-family: Helvetica;
}

.update {
  color: #888888;
  position:absolute;
  top: 10px;
  left: 10px;
  padding: 5px 10px;
  margin: 10px;
  cursor: pointer;
  border: 1px solid #999999;
  border-radius: 3px;
}

.node circle {
  fill: #888888;
  stroke: #fff;
  stroke-width: 2px;
}

.node text {
  fill: #888888;
  stroke: none;
  font-size: .6em;
}

.link {
  stroke: #cccccc;
  stroke-opacity: .6;
}

generate_data.js

function randomData(nodes, width, height) {
  var oldNodes = nodes;
  // generate some data randomly
  nodes = _.chain(_.range(_.random(10, 30)))
    .map(function() {
      var node = {};
      node.key = _.random(0, 30);
      node.size = _.random(4, 10);

      return node;
    }).uniq(function(node) {
      return node.key;
    }).value();

  if (oldNodes) {
    var add = _.initial(oldNodes, _.random(0, oldNodes.length));
    add = _.rest(add, _.random(0, add.length));

    nodes = _.chain(nodes)
      .union(add).uniq(function(node) {
        return node.key;
      }).value();
  }

  links = _.chain(_.range(_.random(15, 35)))
    .map(function() {
      var link = {};
      link.source = _.random(0, nodes.length - 1);
      link.target = _.random(0, nodes.length - 1);
      link.key = link.source + ',' + link.target;
      link.size = _.random(1, 3);

      return link;
    }).uniq((link) => link.key)
    .value();

  maintainNodePositions(oldNodes, nodes, width, height);

  return {nodes, links};
}

function maintainNodePositions(oldNodes, nodes, width, height) {
  var kv = {};
  _.each(oldNodes, function(d) {
    kv[d.key] = d;
  });
  _.each(nodes, function(d) {
    if (kv[d.key]) {
      // if the node already exists, maintain current position
      d.x = kv[d.key].x;
      d.y = kv[d.key].y;
    } else {
      // else assign it a random position near the center
      d.x = width / 2 + _.random(-150, 150);
      d.y = height / 2 + _.random(-25, 25);
    }
  });
}