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);
};
tick
function also always sets the simulation forces over and over again (you could do that once, when you initialise the force). – Quincy