Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React + D3 force layout -- Circles not draggable anymore a few seconds after render

I've been trying to make a draggable d3 force layout in React for a while now. React has to be able to interact with the nodes in the graph. For example, when you click on a node, React should be able to return the node's id onClick.

I made 4 components according to one of Shirley Wu's examples. An App component that holds the graph data in it's state and renders the Graph component. The graph component renders a Node and a Link component. This way, the clickable nodes part worked out.

When the page renders, the nodes will be draggable only for a few seconds though. Immediately after rendering the page you can drag nodes, then suddenly, the node being dragged stops in one position completely. At this point the other nodes cannot be dragged anymore either. I expected to be able to drag the nodes at all times.

I could find a few hints online about creating a canvas behind the graph, setting fill and pointer-events. There are also many discussions about letting or d3 or React do the rendering and calculations. I tried playing with all of React's lifecycle methods, but I can't get it to work.

You can find a live sample over here: https://codepen.io/vialito/pen/WMKwEr

Remember, the circles will be clickable only for a few seconds. Then they'll stay put in the same place. The behavior is the same in all browsers and after every page refresh. When you log the drag function, you'll see that it does assign new coordinates when dragging, the circle won't be displayed in it's new position though.

I'm very eager to learn about the cause of this problem and it would be very cool if you could maybe even propose a solution.

App.js

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

  render() {
    return (
            <div className="graphContainer">
                <Graph data={this.state.data} />
            </div>
        )
    }
}

class Graph extends React.Component {

    componentDidMount() {
        this.d3Graph = d3.select(ReactDOM.findDOMNode(this));
        var force = d3.forceSimulation(this.props.data.nodes);
        force.on('tick', () => {
            force
            .force("charge", d3.forceManyBody().strength(-50))
            .force("link", d3.forceLink(this.props.data.links).distance(90))
            .force("center", d3.forceCenter().x(width / 2).y(height / 2))
            .force("collide", d3.forceCollide([5]).iterations([5]))

            const node = d3.selectAll('g')
                .call(drag)

            this.d3Graph.call(updateGraph)
        });
    }

    render() {
        var nodes = this.props.data.nodes.map( (node) => {
            return (
            <Node
                data={node}
                name={node.name}
                key={node.id}
            />);
        });
        var links = this.props.data.links.map( (link,i) => {
            return (
                <Link
                    key={link.target+i}
                    data={link}
                />);
        });
        return (
            <svg className="graph" width={width} height={height}>
                <g>
                    {nodes}
                </g>
                <g>
                    {links}
                </g>
            </svg>
        );
    }
}

Node.js

    class Node extends React.Component {

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

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

    handle(e){
        console.log(this.props.data.id + ' been clicked')
    }

    render() {
        return (
            <g className='node'>
                <circle ref="dragMe" onClick={this.handle.bind(this)}/>
                <text>{this.props.data.name}</text>
            </g>
        );
    }
}

Link.js

    class Link extends React.Component {

    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' />
        );
    }
}

D3Functions.js

const width = 1080;
const height = 250;
const color = d3.scaleOrdinal(d3.schemeCategory10);
const force = d3.forceSimulation();

const drag = () => {
    d3.selectAll('g')
        .call(d3.drag()
            .on("start", dragStarted)
            .on("drag", dragging)
            .on("end", dragEnded));
};

function dragStarted(d) {
    if (!d3.event.active) force.alphaTarget(0.3).restart()
    d.fx = d.x
    d.fy = d.y

}

function dragging(d) {
    d.fx = d3.event.x
    d.fy = d3.event.y
}

function dragEnded(d) {
    if (!d3.event.active) force.alphaTarget(0)
    d.fx = null
    d.fy = null
}

const enterNode = (selection) => {
    selection.select('circle')
        .attr("r", 30)
        .style("fill", function(d) { return color(d.name) })


    selection.select('text')
        .attr("dy", ".35em")
        .style("transform", "translateX(-50%,-50%")
};

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

};

const enterLink = (selection) => {
    selection.attr("stroke-width", 2)
    .style("stroke","yellow")
        .style("opacity",".2")
};

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

const updateGraph = (selection) => {
    selection.selectAll('.node')
        .call(updateNode)
        .call(drag);
    selection.selectAll('.link')
        .call(updateLink);
};
like image 299
Vincent Avatar asked Feb 23 '18 05:02

Vincent


1 Answers

You define force simulation twice in your code. First time - string 7 in your codepen and second time - string 113. Your dragStarted and dragEnded functions (which are defined globally) use force simulation from string 7, but it not specified (you did not pass nodes, links and other params to it).

enter image description here

You should move these function into the method when you define and specify your force simulation so componentDidMount method for Graph component should look like this (you should also rewrite your tick handler function, and set force params only once (now you do it on each tick), check my fork of your pen):

componentDidMount() {
  this.d3Graph = d3.select(ReactDOM.findDOMNode(this));

  var force = d3.forceSimulation(this.props.data.nodes)
    .force("charge", d3.forceManyBody().strength(-50))
    .force("link", d3.forceLink(this.props.data.links).distance(90))
    .force("center", d3.forceCenter().x(width / 2).y(height / 2))
    .force("collide", d3.forceCollide([5]).iterations([5]))

  function dragStarted(d) {
      if (!d3.event.active) force.alphaTarget(0.3).restart()
      d.fx = d.x
      d.fy = d.y

  }

  function dragging(d) {
      d.fx = d3.event.x
      d.fy = d3.event.y
  }

  function dragEnded(d) {
      if (!d3.event.active) force.alphaTarget(0)
      d.fx = null
      d.fy = null
  }

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

    force.on('tick', () => {
        this.d3Graph.call(updateGraph)
    });
}
like image 189
Mikhail Shabrikov Avatar answered Nov 20 '22 19:11

Mikhail Shabrikov