React + D3 Force Layout: new links have undefined positions
I believe I was following the general update pattern for newer React Props. D3 calculates and renders the data when new props are received, so React doesn't have to render every tick.
D3 works well with static layout. But when I get new nodes and links in the shouldComponentUpdate (nextProps) function, the nodes are missing the following attributes:
- index - the index based on the null value of the node in the node array.
- x - the x coordinate of the current node position.
- y - the y coordinate of the current position of the node.
As a result, all new nodes have <g tranform=translate(undefined, undefined)/>
and are grouped in the upper left corner.
The way I update props is by dragging and dropping new objects into the node array and the link array. I don't understand why D3 doesn't assign dx and dy as it did for the initial setup in the DidMount () component. I have been struggling with this problem for several days. Hope someone can help me here.
Here is ForceLayout.jsx:
//React for structure - D3 for data calculation - D3 for rendering
import React from 'react';
import * as d3 from 'd3';
export default class ForceLayout extends React.Component{
constructor(props){
super(props);
}
componentDidMount(){ //only find the ref graph after rendering
const nodes = this.props.nodes;
const links = this.props.links;
const width = this.props.width;
const height = this.props.height;
this.simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).distance(50))
.force("charge", d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2));
this.graph = d3.select(this.refs.graph);
this.svg = d3.select('svg');
this.svg.call(d3.zoom().on(
"zoom", () => {
this.graph.attr("transform", d3.event.transform)
})
);
var node = this.graph.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr("class", "node")
.call(enterNode);
var link = this.graph.selectAll('.link')
.data(links)
.enter()
.call(enterLink);
this.simulation.on('tick', () => {
this.graph.call(updateGraph);
});
}
shouldComponentUpdate(nextProps){
//only allow d3 to re-render if the nodes and links props are different
if(nextProps.nodes !== this.props.nodes || nextProps.links !== this.props.links){
console.log('should only appear when updating graph');
this.simulation.stop();
this.graph = d3.select(this.refs.graph);
var d3Nodes = this.graph.selectAll('.node')
.data(nextProps.nodes);
d3Nodes
.enter()
.append('g')
.attr("class", "node")
.call(enterNode);
d3Nodes.exit().remove(); //get nodes to be removed
// d3Nodes.call(updateNode);
var d3Links = this.graph.selectAll('.link')
.data(nextProps.links);
d3Links
.enter()
.call(enterLink);
d3Links.exit().remove();
// d3Links.call(updateLink);
const newNodes = nextProps.nodes.slice(); //originally Object.assign({}, nextProps.nodes)
const newLinks = nextProps.links.slice(); //originally Object.assign({}, nextProps.links)
this.simulation.nodes(newNodes);
this.simulation.force("link").links(newLinks);
this.simulation.alpha(1).restart();
this.simulation.on('tick', () => {
this.graph.call(updateGraph);
});
}
return false;
}
render(){
return(
<svg
width={this.props.width}
height={this.props.height}
style={this.props.style}>
<g ref='graph' />
</svg>
);
}
}
/** d3 functions to manipulate attributes **/
var enterNode = (selection) => {
selection.append('circle')
.attr('r', 10)
.style('fill', '#888888')
.style('stroke', '#fff')
.style('stroke-width', 1.5);
selection.append("text")
.attr("x", function(d){return 20}) //
.attr("dy", ".35em") // vertically centre text regardless of font size
.text(function(d) { return d.word });
};
var enterLink = (selection) => {
selection.append('line')
.attr("class", "link")
.style('stroke', '#999999')
.style('stroke-opacity', 0.6);
};
var updateNode = (selection) => {
selection.attr("transform", (d) => "translate(" + d.x + "," + d.y + ")");
};
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);
};
I tried pushing the new node to the node array in the shouldComponentUpdate () function instead of changing the arrays on the server. But the new node still appears in the upper left corner with an undefined position. So I guess my problem is shouldComponentUpdate (). Any help is greatly appreciated!
EDIT: Found that Object.assign (...) does not return an array. Instead, it has been replaced with array.slice (). Now all nodes are displayed from position, but not connected at all. Old parts are worn out and from their original positions.
This is how it looks when new props appear and shouldComponentUpdate is fired
I don't understand why the positions on the links do not match the nodes.
source to share
I solved my problem I found this is a combination of errors:
- Nodes were not linked or the links did not match nodes.
First, my d3 plot and data had different ways of identifying nodes - a graph looking for an index as links when my link data was pointing to objects. I resolved this mismatch problem by changing both values ββto id (i.e. looking for a string). This link suggested by @thedude pointed me on the right path. The solution to this problem led to the correct connection of new nodes.
- The old nodes were still split, bounced further, and the old links remained where they were first displayed.
I suspect this is caused by getting the graph data from d3 which has defined the x, y, vx, vy and index properties. So what I did was get rid of them when I get the currentGraph on the server before I update the data.
removeD3Extras: function(currentGraph) {
currentGraph.nodes.forEach(function(d){
delete d.index;
delete d.x;
delete d.y;
delete d.vx;
delete d.vy;
});
currentGraph.links.forEach(function(d){
d.source = d.source.id;
d.target = d.target.id;
delete d.index;
});
return currentGraph;
}
This is a trick! It now behaves as I intend with no errors on the console, but I zoom in, click and drag.
BUT there is room for improvement:
-
links are on top of nodes
-
sometimes nodes are on top of each other during a tick, which requires dragging.
source to share
links to forceLink
use default object links to point to source
and target
.
You don't show how you create props links
and nodes
, but you can work around it by calling id and setting the access id to point to the boolean id of your node, so if your node has an id property it can be written like this:
.force("link", d3.forceLink(links).id(d => d.id).distance(50))
alternatively you can use the node index as an accessor:
.force("link", d3.forceLink(links).id(d => d.index).distance(50))
or
.force("link", d3.forceLink(links).id((d, i) => i).distance(50))
- Change -
Another measure that might help is to combine the properties of the current nodes with the new nodes, this will allow them to keep the position:
const updatePositions = (newNodes = [], currentNodes = []) => {
const positionMap = currentNodes.reduce((result, node) => {
result[node.id] = {
x: node.x,
y: node.y,
};
return result
}, {});
return newNodes.map(node => ({...node, ...positionMap[node.id]}))
}
then in your shouldComponentUpdate
(note, this is not actually the code where this code should live) you can call it like this:
var nodes = updatePositions(newProps.nodes, this.simulation.nodes())
and use nodes
instead newNodes
.
Note that this code assumes the nodes have a unique id property. Change this to suit your use case
You should also try adding a function key
to your selections to identify your nodes and links, for example:
this.graph.selectAll('.node')
.data(nextProps.nodes, d => d.id) // again assuming id property
source to share