Temporal Network in D3.js, nodes (improperly) restarting positions causing jitter - d3.js

I am working on a temporal network graph, with pie charts as nodes. Along with the nodes/links changing over time, the pie charts are supposed to change. It works fine without incorporating the changing pie slices. When I am incorporating the changing slices I get this weird behaviour where the nodes/pies restart from their initial position every time the (time) slider moves, which makes the whole thing jitter rather severely.
Each nodes data looks like:
{
"id": "Mike",
"start": "2022-08-09",
"location": [12.34, -56.74],
"received": [{
"giver": "Susan",
"receiver": "Mike",
"user_timestamp": "2022-08-09",
"message": "thanks!",
"location": [3.1415, 9.6535]
}, {
"giver": "Joe",
"receiver": "Mike",
"user_timestamp": "2022-08-11",
"message": "so cool!",
"location": [27.18, 2.818]
}]
}
The received array holds all the data pertinent to the pie - each slice is the same size, and there as many slices as their are elements in the array. I am changing the pie slices by filtering the received array based on the user_timestamp and the slider position. Another issue I'm having is that the pie slices are not updating properly when slices are added ...
rangeSlider.on("onchange", (val) => {
currentValue = Math.ceil(timeScale(val));
// this filters entire node/pie
const filteredNodes = userNetworkData.nodes.filter(
(d) => new Date(d.start) <= val
);
// filter the received array in each node
const filteredNodesReceived = filteredNodes.map((d) => {
return {
...d,
received: d.received.filter((r) => new Date(r.user_timestamp) <= val),
};
});
const filteredLinks = userNetworkData.links.filter(
(d) => new Date(d.start) <= val
);
// remove edge if either source or target is not present
const filteredLinksFiltered = filteredLinks.filter(
(d) => filteredNodesReceived.some(o => o.id == d.source.id) && filteredNodesReceived.some(o => o.id == d.target.id)
);
// point to new source, target structure
const filteredLinksMapped = filteredLinksFiltered.map((d) => {
return {
...d,
source: filteredNodesReceived.find(x => x.id==d.source.id),
target: filteredNodesReceived.find(x => x.id==d.target.id)
};
});
update(filteredNodesReceived, filteredLinksMapped);
});
The way its set up I am using userNetworkData to hold the data in some static version so I can bring it back after I have removed it. Maybe that doesn't make sense. I have tried updating the x,y,vx,vy each instance of userNetworkData.nodes on slider change but the same jittering occurs.
filteredLinksMapped is my attempt to re-associate the links with the nodes (which now have a different amount of elements in the received array).
The relevant parts of update(nodes,links):
function update(nodes, links) {
node = node
.data(nodes, (d) => d.id)
.join(
(enter) => enter.append("g").call(drag(simulation))
);
paths = node
.selectAll("path")
.data(function (d, i) {
return pie(d.received);
})
.join(
(enter)=>
enter.append("svg:path")
.attr("class", "path")
.attr("d", arc)
.attr("opacity", 1)
// for making each pie slice visible
.attr("stroke", function (d) {
return color(d.data.receiver);
})
.attr("stroke-width", radius * 0.2)
.attr("fill", function (d, i) {
return color(d.data.giver);
})
.attr("cursor", "pointer")
.on("mousemove", function (event, d) {
//tooltips bit
div.transition().duration(200).style("opacity", 0.9);
// this bit puts the tooltip near the slice of the pie chart
div
.html(parse_message(d.data))
.style("left", event.pageX + 20 + "px")
.style("top", event.pageY - 28 + "px");
})
.on("mouseout", function (d) {
div.transition().duration(500).style("opacity", 0);
})
)
link = link
.data(links, (d) => [d.source, d.target])
.join("line")
.attr("stroke-width", radius * 0.3)
.attr("stroke", (d) => color(d.source.id));
simulation.nodes(nodes);
simulation.force("link").links(links,function(d){return d.id;});
simulation.alpha(0.5).tick();
simulation.restart();
ticked();
}
I am initializing my selections and simulation outside of update like so:
const simulation = d3.forceSimulation()
.force("charge", d3.forceManyBody())
.force(
"link",
d3.forceLink().id((d) => d.id)
)
// .force("collide", d3.forceCollide().radius(2*radius ).iterations(3))
.force(
"y",
d3.forceY((d) => projection(d.location)[1])
)
.force(
"x",
d3.forceX((d) => projection(d.location)[0])
)
.on("tick", ticked);
let link = svg.append("g").attr("class", "links").selectAll("line");
let node = svg.append("g").attr("class", "nodes").selectAll("g");
Note I am forcing the nodes towards the coordinates corresponding to their lat/lon as I am moving towards transitioning between a "map" view and a network view.
Unfortunately i'm having trouble getting to work on codepen, i'll keep trying but hopefully that's enough.

The problem was with the way I was copying over the x,y,vx,vy values from the node selection to the original data (which I would use to filter/add to my selection). Here is what I settled on.
const node_pos_vel = node.data().map(d=> (({ x,y,vx,vy,id }) => ({ x,y,vx,vy,id}))(d))
const node_pos_vel_map = new Map(node_pos_vel.map(d => [d.id, d]));
userNetworkData.nodes = userNetworkData.nodes.map(d => Object.assign(d, node_pos_vel_map.get(d.id)));
The first line is just taking the subset of the object that I want to update. See How to get a subset of a javascript object's properties for how it works.
The last line replaces just the values x,y,vx,vy values for each instance in userNetworkData.nodes when it is a part of the nodes currently in the DOM.
This was inspired by https://observablehq.com/#d3/temporal-force-directed-graph but the difference between their case (where they copy over all the data from node.data() is that I cannot copy over the received array, as the time filter is changing it, and I need to hold a full copy of it.

Related

D3 force directed graph, apply force to a "g" element

I am having trouble with my force directed graph. I had to make some changed to my nodes for design purposes and ever since, my forces stoped working.
Each node is now a "g" element with two "circle" elements inside. One being the background of the node and the other being the partially transparent foreground.
Unlike before where I would apply ".call(drag(simulation))" to my node that used to be a "circle", I now need to apply it the the "g" element.
As seen on the screenshot, the nodes are not where they are supposed to be. They are detached from their respective links, and are all in the center of the map , on the top of each other.
Any clue on what I am doing wrong?
ForceGraph(
nodes, // an iterable of node objects (typically [{id}, …])
links // an iterable of link objects (typically [{src, target}, …])
){
var nodeId = d => d.id // given d in nodes, returns a unique identifier (string)
const nodeStrength = -450 // -1750
const linkDistance = 100
const linkStrokeOpacity = 1 // link stroke opacity
const linkStrokeWidth = 3 // given d in links, returns a stroke width in pixels
const linkStrokeLinecap = "round" // link stroke linecap
const linkStrength =1
var width = this.$refs.mapFrame.clientWidth // scale to parent container
var height = this.$refs.mapFrame.clientHeight // scale to parent container
const N = d3.map(nodes, nodeId);
// Replace the input nodes and links with mutable objects for the simulation.
nodes = nodes.map(n => Object.assign({}, n));
links = links.map(l => ({
orig: l,
//Object.assign({}, l)
source: l.src,
target: l.target
}));
// Construct the forces.
const forceNode = d3.forceManyBody();
const forceLink = d3.forceLink(links).id(({index: i}) => N[i]);
forceNode.strength(nodeStrength);
forceLink.strength(linkStrength);
forceLink.distance(linkDistance)
const simulation = d3.forceSimulation(nodes)
.force(link, forceLink)
.force("charge", forceNode)
.force("x", d3.forceX())
.force("y", d3.forceY())
.on("tick", ticked);
const svg = d3.create("svg")
.attr("id", "svgId")
.attr("preserveAspectRatio", "xMidYMid meet")
.attr("viewBox", [-width/2,-height/2, width,height])
.classed("svg-content-responsive", true)
const defs = svg.append('svg:defs');
defs.selectAll("pattern")
.data(nodes)
.join(
enter => {
// For every new <pattern>, set the constants and append an <image> tag
const patterns = enter
.append("pattern")
.attr("preserveAspectRatio", "none")
.attr("viewBox", [0,0, 100,100])
.attr("width", 1)
.attr("height", 1);
patterns
.append("image")
.attr("width", 80)
.attr("height", 80)
.attr("x", 10)
.attr("y", 10);
return patterns;
}
)
// For every <pattern>, set it to point to the correct
// URL and have the correct (company) ID
.attr("id", d => d.id)
.select("image")
.datum(d => {
return d;
})
.attr("xlink:href", d => {
return d.image
})
const link = svg.append("g")
.attr("stroke-opacity", linkStrokeOpacity)
.attr("stroke-width", linkStrokeWidth)
.attr("stroke-linecap", linkStrokeLinecap)
.selectAll("line")
.data(links)
.join("line")
;
link.attr("stroke", "white")
var node = svg
.selectAll(".circle-group")
.data(nodes)
.join(enter => {
node = enter.append("g")
.attr("class", "circle-group");
node.append("circle")
.attr("class", "background") // classes aren't necessary here, but they can help with selections/styling
.style("fill", "red")
.attr("r", 30);
node.append("circle")
.attr("class", "foreground") // classes aren't necessary here, but they can help with selections/styling
.style("fill", d => `url(#${d.id})`)
.attr("r", 30)
})
node.call(drag(simulation))
function ticked() {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
//.transform("translate", d => "translate("+[d.x,d.y]+")"); // triggers error
.attr("transform", d => "translate("+[d.x,d.y]+")");
//.attr("cx", d => d.x)
//.attr("cy", d => d.y);
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
return Object.assign(svg.node() );
}//forcegraph
EDIT 1
I updated the ticked() function with what was suggested but
".transform("translate", d => "translate("+[d.x,d.y]+")");" triggered the following error :
Mapping.vue?d90b:417 Uncaught TypeError: node.transform is not a function
at Object.ticked (Mapping.vue?d90b:417:1)
at Dispatch.call (dispatch.js?c68f:57:1)
at step (simulation.js?5481:32:1)
at timerFlush (timer.js?74f4:61:1)
at wake (timer.js?74f4:71:1)
So I changed it for ".attr("transform", d => "translate("+[d.x,d.y]+")");"
I don't get any error anymore but my nodes are still all in the center of the map as per the initial screenshot.
I am not quite sure what I am doing wrong. Perhaps I need to call ".call(drag(simulation))" on each of the two circles instead of calling it on node?
g elements don't have cx or cy properties. Those are specific to circle elements (and ellipses). This is why your positioning does not work. However, both circle and g can use a transform for positioning. Instead of:
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
You can use:
node
.transform("translate", d => "translate("+[d.x,d.y]+")");
In regards to your question title, d3 does not apply a force to the elements but rather the data itself. The forces continue to work regardless of whether you render the changes - as seen in your case by the links which move as they should.

d3 General Update Pattern

I just mimic the code d3 update pattern trying to render some rect with updated data
here is my code.
function update(data){
var r = g.selectAll("rect").data(data,function(d){return (d)});
r.exit().attr("class","exit").remove()
r
.attr("class","update")
.attr("x",(d, i) =>{return i* (50+interval)})
.attr("y", (d)=>{ return y(d)})
.attr("width", "20px")
.transition(t)
.attr("height",( d => { return height-padding.top-padding.bottom-y(d);}))
r.enter()
.append("rect")
.attr("width", "20px")
.attr("class","new")
.attr("x",(d, i) =>{ return i * (50+interval)})
.attr("y", (d)=>{return y(d)})
.attr("height",( d => { return height-padding.top-padding.bottom-y(d);}))
}
then I call the update function twice
update([3,2,1,5,4,10,9,7,8,6])
setTimeout(()=>{update([2,3,1,5,4,10,9,7,8,6])},1000)
Expected: only the first and second rect will be rerendered and set class "new", but in fact, all the rect will be set class "new" .
Codepen
The enter/exit pattern works when the data is an array of identified objects.
Replace this code:
var r = g.selectAll("rect").data(data,function(d){return (d)});
with:
const _data = data.map((v,i) => ({id: i, value: v}));
const r = g.selectAll("rect").data(_data,d => d.id);
The D3 will identify each object and update it accordingly instead of replacing with a new one.
See it's working in a pen
UPD:
If you want to highlight the items whose values have been changed, you can save the current value in an attribute of a newly added item:
r.enter()
.append("rect")
.attr('cur-value', d => d.value)
...
then, on update, query the value and compare with the one in datum:
r.attr("class","update")
...
.each(function(d) {
const rect = d3.select(this);
const prevValue = parseInt(rect.attr('cur-value'));
rect.attr('cur-value', d.value);
rect.style('fill', prevValue === d.value ? 'black' : 'red')
});
You can see it's working in the updated pen.

How to dynamically update this D3JS force diagram

I have adapted this force directed graph example from D3JS, https://observablehq.com/#d3/force-directed-graph. I am calling the code from with VueJS, so I have implemented it as a class.
The initial graph works fine, but when I try to add new data, it does not respond. As far as I can see, provided I can access the D3 edge and vertex selections, I should be able to call their join() function at any time, feeding in new data, and that should automatically merge it. Perhaps I am mistaken?
The code below is reasonably long, but there are only two notable methods. Firstly, once the D3Diagram object has been instantiated, I call initialise(), with arrays of edges and vertices. Later, when I want to add more data, I call the addMoreData() method, with the new arrays.
import * as d3 from "d3";
export default class D3Diagram {
initialise(vertices, edges) {
this.vertices = vertices;
this.edges = edges;
this.createSvg();
this.createSimulation();
this.createEdgeSelection();
this.createVertexSelection();
this.configureSimulation();
}
createSvg() {
this.svg = d3.select('#diagram-wrapper')
.append('svg')
.attr('viewBox', [0, 0, 100, 100])
.classed('flex-grow-1', true);
}
createSimulation() {
this.simulation = d3.forceSimulation(this.vertices)
.force("link", d3.forceLink(this.edges).id(d => d.id))
.force("charge", d3.forceManyBody().strength(d => -4))
.force("center", d3.forceCenter(50, 50));
}
createEdgeSelection() {
this.edgeSelection = this.svg.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(this.edges)
.join("line");
this.edgeSelection.append("title").text(d => d.id);
}
createVertexSelection() {
this.vertexSelection = this.svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 0.5)
.selectAll("circle")
.data(this.vertices)
.join("circle")
.attr("r", 2)
.attr("fill", color)
.call(drag(this.simulation));
}
configureSimulation() {
this.simulation.on('tick', () => {
this.edgeSelection
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
this.vertexSelection
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});
}
addMoreData(vertices, edges) {
this.vertexSelection.join(vertices);
this.edgeSelection.join(edges);
}
}
function color() {
const scale = d3.scaleOrdinal(d3.schemeCategory10);
return d => scale(d.group);
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
In D3, the data is bound to the selections. You do the binding with the data() function.
If you want to update your selections, take your existing selection and bind data again. This creates a new selection, and the enter, update, and exit selections of that new selection will contain the changed elements.
For example, changing data:
// Initial, empty selection.
this.vertexSelection = this.svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 0.5)
// Bind the data once.
this.vertexSelection.data(data)
// Data join, creating new elements.
this.vertexSelection.join("line")
// Bind the data again.
this.vertexSelection.data(data)
// Data join. Will update, removing old data, appending new data,
// updating existing data.
this.vertexSelection.join("line")
You'll note that, by design, the calls when data is updated is always the same, save for initializing the selection. So we can put all of that in a render() method that's idempotent: it'll work regardless of if this is the first call or the second, third and so on.. call.
Afterwards, you can split it up the same way you did in your current code. You can call init() & render() on component mount, to do the first render, and render() whenever you have new data.
// pseudocode
function init() {
// Initial, empty selection.
this.vertexSelection = this.svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 0.5)
}
function render(data) {
// Bind new data and do the data join.
this.vertexSelection
.data(this.data.vertexes)
.join("line")
}
mounted() {
this.init()
this.render()
}
// on data change... {
this.render()
// }
For more in-depth information, check out the selection.join() tutorial.

d3.js sunburst with dynamically built updatable data structure

I am attempting to create a fully dynamic Sunburst graph using d3.js.
The examples and tutorials I have located tend to use existing/fully-populated data structures which may have the ability to modify the value of existing arcs but does not allow the ability to add child arcs as needed.
Likewise the tutorials I have located which allow new datasets simply replace the existing structure and begin drawing from scratch.
This is not the behavior I am trying to implement.
What I need is a dynamically built graph based on incoming data as it is provided.
I am able to append children to the end of the data set, transition and render the results without issue. The problem occurs any time I insert a child somewhere within the existing structure, d3’s selectAll() does not function as expected. It includes the new arc (which has yet to be drawn) resulting in any remaining arcs being rendered incorrectly. Then when transitioning the arcs it seems to get the arcs Dom ID and data it supposedly represents gets mixed up. The new arc is not visible and an empty space exists where new arc should be placed.
To be clear my intent is:
Add to the existing data structure allowing new children to be added when new information is provided
To transition existing arcs opening space for the new arcs before they are created and drawn
Broken down into four steps of the jsfiddle example:
Initialization of the graph (draws an invisible “root” arc)
{ name:"a_0", children: [] }
Adding First Child data and it’s children to root
{ name:"a_0", children:[
{ name:"a_1", children:[ { name:"a_2", children:[ { name:"a_3" } ] } ] }
] }
Adding Second Child and underlying children to root
{ name:"a_0", children:[
{ name:"a_1", children:[ { name:"a_2", children:[ { name:"a_3" } ] } ] },
{ name:"a_4", children:[ { name:"a_5", children:[ { name:"a_6" } ] } ] }
] }
Inserting another child within the existing arc a_2
{ name:"a_0", children:[
{ name:"a_1", children:[
{ name:"a_2", children:[
{ name:"a_3" },
{ name:"a_7" }
] }
] },
{ name:"a_4", children:[
{ name:"a_5", children:[
{ name:"a_6" }
] }
] }
] }
Step 1 works just fine
Step 2 draws the arcs properly
Step 3 transitions the existing arcs and adds the new arcs to the graph
Step 4 results some unexpected behavior.
During the transition of existing and entering of new arcs some of the arcs "jump around" losing the proper association with their respective data
The end result appears to be:
a_0 - is correct
a_1 & a_2 - look correct
a_3 - has shrunk to accommodate the new sibling a_7 - expected behavior
a_4 - disappears
a_5 - jumps down where a_4 should be
a_6 - (looks like) it is duplicated and exists once where it should be and where a_5 should be
a_7 - not displayed, location where it should be is empty space and appears to be associated with a_6 data
What the end result looks like and what is really going on are not the same.
In the attempt to update the graph the selectAll() for the existing arcs includes (a_0, a_1, a_2, a_3, a_4, a_5, a_7). Where the existing a_6 is not included in the selectAll() but a_7 (which has not been drawn) is.
The enter() function appears to operate on the existing a_6 which is then treated as a new arc
It looked like I was on the right track getting all the way to a_6, but I have not figured out the reason for the behavior when adding a_7.
The jsFidde executes the steps as described above including:
Unique colors for each arc
A table displaying the name of each arc,
If the arc is being handled by d3js' selectAll() (i.e. "existing") or enter() (i.e. "new"),
The d3 Index as it is currently being assigned when drawing existing or new arcs.
Expected target position where each arc should appear after any transitioning,
Arctween information as an Arc is being transitioned from its former location to the new location and
Questions:
What is going on that would cause this behavior in Step 4?
Is there a way to ensure the integrity between each arc and the data it represents?
Is there a way to insert children into the existing structure or update the graph in this dynamic manor?
Working example on jsfiddle https://jsfiddle.net/mfitzgerald/j2eowwya/
var dataObj = { name:"a_0", color: "none" };
var height = 300;
var width = 500;
var radius = Math.min(width, height) / 2;
var graph = d3.select("#graph")
.attr('height', height)
.attr('width', width)
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var partition = d3.layout.partition()
.sort(null)
.size([2 * Math.PI, radius * radius])
.value(function(d, i) { return 1; });
var arc = d3.svg.arc()
.startAngle(function(d) { if (isNaN(d.x)) { d.x = 0; } d.x0 = d.x; return d.x; })
.endAngle(function(d) { if (isNaN(d.dx)) { d.dx = 0; } d.dx0 = d.dx; return d.x + d.dx; })
.innerRadius(function(d) { if (isNaN(d.y)) { d.y = 0; } d.y0 = d.y; return Math.sqrt(d.y); })
.outerRadius(function(d) { if (isNaN(d.dy)) { d.dy = 0; } d.dy0 = d.dy; return Math.sqrt(d.y + d.dy); });
var arcTween = function(a) {
var i = d3.interpolate({x: a.x0, dx: a.dx0, y: a.y0, dy: a.dy0}, a);
return function(t) {
var b = i(t);
a.x0 = b.x;
a.dx0 = b.dx;
a.y0 = b.y;
a.dy0 = b.dy;
displayStats("arctween", b);
return arc(b);
};
}
// Root Arc
graph.datum(dataObj).selectAll('path.arc')
.data(partition.nodes)
.enter()
.append('path')
.attr('class', function(d) { return "arc " + d.name; })
.attr("d", arc)
.attr("id", function(d, i) { return "path_"+i; })
.attr("name", function(d) { return d.name; })
.style("fill", "none");
function updateGraph() {
console.log("Update Graph");
console.log(dataObj);
var update = graph.datum(dataObj).selectAll('path.arc')
.data(partition.nodes);
// Move existing Arcs
update.each(function(d, i) {
displayStats("target", d, i, "existing");
var domId = $(this).attr("id");
console.log("["+i+"] Exist Arc name:"+d.name+", dom_id:"+domId);
})
.transition()
.delay(function(d, i) { return i * 250; })
.duration(1500)
.attrTween("d", arcTween);
// Add New Arcs
update.enter().append('path')
.attr('class', function(d, i) { return "arc "+d.name; })
.attr("d", arc)
.attr("id", function(d, i) {
var domId = "path_"+i;
console.log("["+i+"] NEW Arc name:"+d.name+", dom_id:"+domId);
displayStats("target", d, i, "new");
return domId;
})
.style("stroke", "#fff")
.style("fill", function(d) { return d.color; })
.style("opacity", 0)
.transition()
.delay(function(d, i) { return i * 250; })
.duration(1500)
.style("opacity", .5)
.attrTween("d", arcTween);
}
#Gordon has answered the question. The issue was resolved by adding a key function when joining with .data() in the updateGraph code.
Forked example on jsfiddle
var update = graph.datum(dataObj).selectAll('path.arc')
.data(partition.nodes, function(d) { return d.name; } );
I believe the answers to the questions are:
The .data() function uses an indexed array which only uniquely identifies each arc given any new arcs are appended to the end of the array. Once one is inserted this would cause the data, graphed arcs and associated DOM ids to be misaligned.
Using the key function, as suggested by Gordon, allows unique identification of specific nodes keeping the Data and Graph in sync as expected.
Update
An additional modification would need to be made as the DOM id was set by the array index of the data element there would still be an invalid association with the DOM and the underlying graph/data.
This would result in 2 a_4 DOM id's. Instead of using the array index using the Node Name as the DOM id should keep this association correct.

d3 force map renders the links incorrectly after re-render

I have made an org hierarchy chart using react and Force layout. Org object has a user and defines user's relationship with others at work - like boss, coworkers, subordinates. A new person can be dynamically added in the org map which re-renders the map with new information.
However, after re-render, the map displays the links and relation text incorrectly. Even the names on the nodes get incorrectly assigned even though the data associated with node is correct. With debugging, I found that links, nodes and linklabels objects - all are correct. But the enter and exit seems a little funky and could be the source of the problem.
I have a jsfiddle to simulate the bug.
jsfiddle initially renders an org map with four nodes. Joe is the user and he has a boss John, coworker Shelley, and subordinate Maria.
I have created a button to simulate dynamic adding of a new person. Clicking the button will add (data is hard coded for bug simulation) Kelly as co-worker to Maria and re-render the map. You will notice that after the render, all the links and labels are incorrect. However, when I look at the data associated with nodes in debug mode, it's correct.
I have spent a lot of time trying to figure this out but can't seem to catch the bug.
The jsfiddle is written in react. If you are not familiar with react, please ignore the react code and just focus on d3 code.
The jsfiddle code is pasted here:
Javascript:
const ForceMap = React.createClass({
propTypes: {
data: React.PropTypes.object,
width: React.PropTypes.number,
height: React.PropTypes.number
},
componentDidMount(){
let {width,height} = this.props;
this.forceLayout = d3.layout.force()
.linkDistance(100)
.charge(-400)
.gravity(.02)
.size([width, height])
this.svg = d3.select("#graph")
.append("svg")
.attr({id:'#org-map',width:width,height:height,backgroundColor:'white'})
let container = this.svg.append("g").attr('class','container');
let rect = container.append("rect")
.attr({width:width,height:height})
.style({fill:"white","pointer-events":"all"})
this.org = this.props.data;
this.org.x = width / 2;
this.org.y = height / 2;
this.org.fixed = true;
console.log('Initial Org:',this.org);
this.d3render(this.org);
}, //componentDidMount
d3render(org) {
let container = d3.selectAll('g.container')
let nodes = this.flatten(org);
let links = d3.layout.tree().links(nodes);
let force = this.forceLayout.on("tick", tick);
force.nodes(nodes) // Restart the force layout.
.links(links)
.start();
debugger;
// Links line that connects two org members together
let link = container.selectAll(".link").data(links);
link.exit().remove()
link.enter().append("line")
.attr('class',"link")
.attr('id', (d)=> d.source.name + '-' +d.target.name)
console.log('link:',link);
//Relationship label for every link
let linkLabel = container.selectAll(".linklabelholder").data(links);
linkLabel.exit().remove();
linkLabel.enter()
.append("g")
.attr("class", "linklabelholder")
.attr('id', (d) => `linklabel-${d.source.name}-${d.target.name}`)
.append("text")
.attr({dx:1, dy: ".35em", "text-anchor": "middle"})
.text((d) => d.target.relation)
.style("font-size",12);
console.log('link Labels: ',linkLabel);
// Update nodes. Each node represents one person
let node = container.selectAll(".node").data(nodes);
node.exit().remove();
let nodeEnter = node.enter()
.append("g")
.attr("class", "node")
.attr('id', (d) => `node-${d.name}`)
nodeEnter.append('circle')
.attr('r',25)
.attr('id',(d) => d.name)
.style('fill', 'steelblue')
nodeEnter.append("text")
.attr("dy", ".35em")
.text((d) => d.name)
.attr('id', (d) => d.name)
.style("font-size",12);
console.log('Nodes: ',node);
function tick() {
node.attr("cx", function(d) { return d.x = Math.max(25, Math.min(475, d.x)); })
.attr("cy", function(d) { return d.y = Math.max(25, Math.min(275, d.y)); });
link.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y)
linkLabel.attr("transform", (d) => `translate(${(d.source.x+d.target.x)/2},${(d.source.y+d.target.y)/2})`);
node.attr("transform", (d) => `translate(${d.x},${d.y})`)
} //tick
}, //d3 render
addNewPerson() {
let newPerson = {_id: "5",name: 'Kelly' ,relation:'coworker'};
let addTo = {_id:"4", name: "Maria"};
add(this.org);
console.log('RE-RENDER AFTER ADDING NEW PERSON');
console.log('Org after addding new person: ', this.org);
this.d3render(this.org);
function add(node) {
if (node.children) node.children.forEach(add);
if (node._id === addTo._id) {
if (!node.children) node.children = [];
node.children.push(newPerson);
}
}
},
flatten(org) {
var nodes = [], i = 0;
recurse(org);
return nodes;
function recurse(node) {
if (node.children) node.children.forEach(recurse);
if (!node.id) node.id = ++i;
nodes.push(node);
}
}, //flatten
render() {
return (
<div>
<div id="graph"></div>
<button className='btnClass' onClick={this.addNewPerson} type="submit">Add new person
</button>
</div>
);
},
});
var user = {
name: 'Joe',
_id: "1",
children:[
{_id:"2", name: "John", relation:"boss"},
{ _id:"3", name: "Shelley", relation:"coworker"},
{_id:"4", name: "Maria", relation:"subordinate"}
]
}
ReactDOM.render(
<ForceMap
data={user}
width={500}
height={300}
/>,
document.getElementById('container')
);
You need key functions for your selections to make anything other than simple datum-element binding work properly, otherwise they just replace/overwrite each other based on index:
https://github.com/mbostock/d3/wiki/Selections#data
In this case, these will work:
let link = container.selectAll(".link").data(links, function(d) { return d.source_id+"-"+d.target._id+"-"+d.target.relation; });
...
let linkLabel = container.selectAll(".linklabelholder").data(links, function(d) {
return d.source._id+"-"+d.target._id+"-"+d.target.relation;
});
...
let node = container.selectAll(".node").data(nodes, function(d) { return d._id; });
https://jsfiddle.net/aqu1h7zr/3/
As for why new links overwrite nodes it's because elements are drawn in the order they are encountered in the dom. Your new links are added in the dom after your old nodes so they're drawn on top. To get round this add nodes and links to separate 'g' elements (I haven't done this in the updated fiddle), so that all links are drawn first
e.g. not
<old links>
<old nodes>
<new links>
<new nodes>
but
<g>
<old links>
<new links>
</g>
<g>
<old nodes>
<new nodes>
</g>

Resources