I am trying to add nodes to a D3 force directed graph dynamically. I believe I am close, but am not understanding some critical concept related to how selections in D3 work.
If I call my addData
function directly and add all of the nodes at once, everything works as expected.
However, if I use the addDataInChunks
, the first two nodes and first link are added, but after that no further nodes appear in the graph. Additionally, the ability to drag nodes around is lost as soon as the second chunk is added to the graph.
What have I done wrong? How does my code need to change so this will work?
const nodeData = [
{
id: 'node 1',
added: false,
},
{
id: 'node 2',
added: false,
},
{
id: 'node 3',
added: false,
},
{
id: 'node 4',
added: false,
},
]
const linkData = [
{
linkID: 'link 1',
added: false,
source: 'node 1',
target: 'node 2',
},
{
linkID: 'link 2',
added: false,
source: 'node 1',
target: 'node 3',
},
{
linkID: 'link 3',
added: false,
source: 'node 3',
target: 'node 4',
},
]
//
//
//
let svg = null
let node = null
let link = null
let simulation = null
const width = 750
const height = 400
const nodeCount = 10
const svgNS = d3.namespace('svg:text').space
function setupGraph() {
svg = d3.select('#chart').call(d3.zoom().on('zoom', zoomed)).append('g')
node = svg.append('g').attr('stroke', '#fff').attr('stroke-width', 1.5)
link = svg.append('g').attr('stroke', '#999').attr('stroke-opacity', 0.6)
}
const simulationNodes = []
const simulationLinks = []
function addData(addNodes, addLinks) {
const links = addLinks.map((d) => ({ ...d }))
const nodes = addNodes.map((d, index) => ({ ...d }))
console.log(`🚀 ~ nodes:`, nodes)
console.log(`🚀 ~ links:`, links)
simulationNodes.push(...nodes)
simulationLinks.push(...links)
simulation = d3
.forceSimulation(simulationNodes)
.force(
'link',
d3
.forceLink(simulationLinks)
.id((d) => d.id)
.distance((d) => 50)
)
.force('charge', d3.forceManyBody().strength(-400))
.force('x', d3.forceX())
.force('y', d3.forceY())
.on('tick', ticked)
node = node
.selectAll()
.data(nodes)
.join((enter) => {
return enter.append((d) => {
const circleElement = document.createElementNS(svgNS, 'circle')
circleElement.setAttribute('r', 16)
circleElement.setAttribute('fill', '#318631')
circleElement.setAttribute('stroke', '#7CC07C')
circleElement.setAttribute('stroke-width', '3')
return circleElement
})
})
node.append('title').text((d) => `hello ${d.id}`)
node.call(d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended))
link = link.selectAll().data(links).join('line').attr('stroke-width', 1)
}
setupGraph()
// addData(nodeData, linkData)
async function addDataInChunks(allNodes, allLinks) {
const timeout = 7000
let nodeChunk = allNodes.slice(0, 2)
let linkChunk = allLinks.slice(0, 1)
addData(nodeChunk, linkChunk)
await new Promise((r) => setTimeout(r, timeout))
//
nodeChunk = allNodes.slice(2, 3)
linkChunk = allLinks.slice(1, 2)
addData(nodeChunk, linkChunk)
await new Promise((r) => setTimeout(r, timeout))
//
nodeChunk = allNodes.slice(3, 4)
linkChunk = allLinks.slice(2, 3)
addData(nodeChunk, linkChunk)
await new Promise((r) => setTimeout(r, timeout))
console.log('addDataInChunks finished')
}
addDataInChunks(nodeData, linkData)
//
// Misc Functions
//
function zoomed(transform) {
const t = transform.transform
const container = this.getElementsByTagNameNS(svgNS, 'g')[0]
const transformString = 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')'
container.setAttribute('transform', transformString)
}
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.each(function (d) {
this.setAttribute('cx', d.x)
this.setAttribute('cy', d.y)
})
}
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
}
.graph {
width: 750px;
height: 400px;
}
<script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>
<svg ref="chart" id="chart" class="graph" style="background-color: #141621"></svg>
Here's the summary of my changes:
First, you need to selectAll
on a "parent" of the elements. You are selecting on the selection which doesn't make sense. This works the first go-around because all the data is "entering" but subsequently d3 won't find the already present bound data. You should also explicitly selectAll
on an element or class.
Second, while you are appending and keeping track of the simulationNodes
and simulationLinks
you need to pass that same appended data to the selection. Otherwise, d3 can't calculate the entering vs updating data.
Third, you need to manage the life-cycle of the simulation. Meaning you need to stop it and restart it after modifying its data.
Fourth, I cleaned up your data-joins. I found the use of native svg methods strange when mixed with d3
. I also modified them to have explicit enter, update, and exit methods. You aren't using the exit but I included it for completeness sake.
Finally, I did some general clean-up like clearer variable names, not re-creating the simulation on each data modification and moving the graph to the center of the svg.
<!DOCTYPE html>
<html>
<head>
<style>
.graph {
width: 750px;
height: 400px;
}
</style>
</head>
<body>
<script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>
<svg
ref="chart"
id="chart"
class="graph"
style="background-color: #141621"
></svg>
<script>
const nodeData = [
{
id: 'node 1',
added: false,
},
{
id: 'node 2',
added: false,
},
{
id: 'node 3',
added: false,
},
{
id: 'node 4',
added: false,
},
];
const linkData = [
{
linkID: 'link 1',
added: false,
source: 'node 1',
target: 'node 2',
},
{
linkID: 'link 2',
added: false,
source: 'node 1',
target: 'node 3',
},
{
linkID: 'link 3',
added: false,
source: 'node 3',
target: 'node 4',
},
];
//
//
//
let svg = null;
const width = 750;
const height = 400;
const nodeCount = 10;
function setupGraph() {
svg = d3
.select('#chart')
.call(d3.zoom().on('zoom', zoomed))
.append('g');
nodeGroup = svg
.append('g')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5);
linkGroup = svg
.append('g')
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6);
}
// nodes and links are the d3 selections
let nodes = null;
let links = null;
// currentNodes and currentLinks is the current data
const currentNodes = [];
const currentLinks = [];
// the link simulation
const linkSim = d3
.forceLink()
.id((d) => d.id)
.distance((d) => 50);
// overall simulation
const simulation = d3
.forceSimulation()
.force('link', linkSim)
.force('charge', d3.forceManyBody().strength(-400))
.force('x', d3.forceX())
.force('y', d3.forceY())
.force('center', d3.forceCenter(width / 2, height / 2))
.on('tick', ticked);
function addData(addNodes, addLinks) {
addNodes = addNodes.map((d, index) => ({ ...d }));
addLinks = addLinks.map((d) => ({ ...d }));
currentNodes.push(...addNodes);
currentLinks.push(...addLinks);
nodes = nodeGroup
.selectAll('circle')
.data(currentNodes, (d) => d)
.join(
(enter) => {
let e = enter
.append('circle')
.attr('r', 16)
.attr('fill', '#318631')
.attr('stroke', '#7CC07C')
.attr('stroke-width', '3');
e
.append('title')
.text((d) => `hello ${d.id}`);
return e;
},
(update) => update,
(exit) => exit.remove()
);
nodes.call(
d3
.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended)
);
links = linkGroup
.selectAll('line')
.data(currentLinks)
.join(
(enter) => enter.append('line').attr('stroke-width', 1),
(update) => update,
(exit) => exit.remove()
);
// stop and start the simulation
simulation.stop();
simulation.nodes(currentNodes);
linkSim.links(currentLinks);
simulation.alpha(0.3).restart();
}
setupGraph();
async function addDataInChunks(allNodes, allLinks) {
const timeout = 7000;
let nodeChunk = allNodes.slice(0, 2);
let linkChunk = allLinks.slice(0, 1);
addData(nodeChunk, linkChunk);
await new Promise((r) => setTimeout(r, timeout));
//
nodeChunk = allNodes.slice(2, 3);
linkChunk = allLinks.slice(1, 2);
addData(nodeChunk, linkChunk);
await new Promise((r) => setTimeout(r, timeout));
//
nodeChunk = allNodes.slice(3, 4);
linkChunk = allLinks.slice(2, 3);
addData(nodeChunk, linkChunk);
await new Promise((r) => setTimeout(r, timeout));
console.log('addDataInChunks finished');
}
addDataInChunks(nodeData, linkData);
//
// Misc Functions
//
function zoomed(transform) {
const t = transform.transform;
const container = this.getElementsByTagNameNS(svgNS, 'g')[0];
const transformString =
'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')';
container.setAttribute('transform', transformString);
}
function ticked() {
links
.attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y);
nodes.each(function (d) {
this.setAttribute('cx', d.x);
this.setAttribute('cy', d.y);
});
}
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;
}
</script>
</body>
</html>