I'm using d3.js v6 with a force layout to represent a network graph.
I'm adding and removing nodes but when I restart the simulation all the nodes jump to an upper left position and then come back to the original position.
I have this following code snippet that shows exactly what I mean, I've seen other examples online that work fine but haven't been able to find what I am doing wrong, any help is really appreciated.
var dataset = {
nodes: [
{
id: 1
},
{
id: 2
}
],
links: [{
id: 1,
source: 1,
target: 2
}]
};
let switchBool = false;
let svg = d3.select('svg')
.attr('width', '100%')
.attr('height', '100%');
const width = svg.node()
.getBoundingClientRect().width;
const height = svg.node()
.getBoundingClientRect().height;
console.log(`${width}, ${height}`);
svg = svg.append('g');
svg.append('g')
.attr('class', 'links');
svg.append('g')
.attr('class', 'nodes');
const simulation = d3.forceSimulation();
initSimulation();
let link = svg.select('.links')
.selectAll('line');
loadLinks();
let node = svg.select('.nodes')
.selectAll('.node');
loadNodes();
restartSimulation();
function initSimulation() {
simulation
.force('link', d3.forceLink())
.force('charge', d3.forceManyBody())
.force('collide', d3.forceCollide())
.force('center', d3.forceCenter())
.force('forceX', d3.forceX())
.force('forceY', d3.forceY());
simulation.force('center')
.x(width * 0.5)
.y(height * 0.5);
simulation.force('link')
.id((d) => d.id)
.distance(100)
.iterations(1);
simulation.force('collide')
.radius(10);
simulation.force('charge')
.strength(-100);
}
function loadLinks() {
link = svg.select('.links')
.selectAll('line')
.data(dataset.links, (d) => d.id)
.join(
enter => enter.append('line').attr('stroke', '#000000'),
);
}
function loadNodes() {
node = svg.select('.nodes')
.selectAll('.node')
.data(dataset.nodes, (d) => d.id)
.join(
enter => {
const nodes = enter.append('g')
.attr('class', 'node')
nodes.append('circle').attr('r', 10);
return nodes;
},
);
}
function restartSimulation() {
simulation.nodes(dataset.nodes);
simulation.force('link').links(dataset.links);
simulation.alpha(1).restart();
simulation.on('tick', ticked);
}
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.attr('transform', (d) => `translate(${d.x},${d.y})`);
}
function updateData() {
switchBool = !switchBool;
if (switchBool) {
dataset.nodes.push({id: 3});
dataset.links.push({id: 2, source: 1, target: 3});
} else {
dataset.nodes.pop();
dataset.links.pop();
}
loadLinks();
loadNodes();
restartSimulation();
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div>
<button onclick="updateData()">Add/Remove</button>
<svg></svg>
</div>
It's because you use d3.forceCenter() which does not coerce nodes to a center point:
The centering force translates nodes uniformly so that the mean
position of all nodes (the center of mass if all nodes have equal
weight) is at the given position ⟨x,y⟩. (docs)
So if your two nodes are located at directly and equally below/above the centering point for d3.forceCenter, the mass is balanced. Introduce a new node and the entire force has to be transalted so that the center of mass is the center. This translation is the jump you are seeing.
Remove forceCenter and specify the center values with d3.forceX and d3.forceY, which do nudge nodes towards the specified x and y values:
var dataset = {
nodes: [
{
id: 1
},
{
id: 2
}
],
links: [{
id: 1,
source: 1,
target: 2
}]
};
let switchBool = false;
let svg = d3.select('svg')
.attr('width', '100%')
.attr('height', '100%');
const width = svg.node()
.getBoundingClientRect().width;
const height = svg.node()
.getBoundingClientRect().height;
console.log(`${width}, ${height}`);
svg = svg.append('g');
svg.append('g')
.attr('class', 'links');
svg.append('g')
.attr('class', 'nodes');
const simulation = d3.forceSimulation();
initSimulation();
let link = svg.select('.links')
.selectAll('line');
loadLinks();
let node = svg.select('.nodes')
.selectAll('.node');
loadNodes();
restartSimulation();
function initSimulation() {
simulation
.force('link', d3.forceLink())
.force('charge', d3.forceManyBody())
.force('collide', d3.forceCollide())
.force('forceX', d3.forceX().x(width/2))
.force('forceY', d3.forceY().y(height/2));
simulation.force('link')
.id((d) => d.id)
.distance(100)
.iterations(1);
simulation.force('collide')
.radius(10);
simulation.force('charge')
.strength(-100);
}
function loadLinks() {
link = svg.select('.links')
.selectAll('line')
.data(dataset.links, (d) => d.id)
.join(
enter => enter.append('line').attr('stroke', '#000000'),
);
}
function loadNodes() {
node = svg.select('.nodes')
.selectAll('.node')
.data(dataset.nodes, (d) => d.id)
.join(
enter => {
const nodes = enter.append('g')
.attr('class', 'node')
nodes.append('circle').attr('r', 10);
return nodes;
},
);
}
function restartSimulation() {
simulation.nodes(dataset.nodes);
simulation.force('link').links(dataset.links);
simulation.alpha(1).restart();
simulation.on('tick', ticked);
}
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.attr('transform', (d) => `translate(${d.x},${d.y})`);
}
function updateData() {
switchBool = !switchBool;
if (switchBool) {
dataset.nodes.push({id: 3});
dataset.links.push({id: 2, source: 1, target: 3});
} else {
dataset.nodes.pop();
dataset.links.pop();
}
loadLinks();
loadNodes();
restartSimulation();
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div>
<button onclick="updateData()">Add/Remove</button>
<svg></svg>
</div>
I've actually just found the solution to the problem.
I had both forceX and forceY with default paramaters, which meant that there was a force pushing node towards (0,0), changing this bit of code I was able to fix it:
.force('x', d3.forceX().x(width * 0.5))
.force('y', d3.forceY().y(height * 0.5));
I'm trying to create a dynamic bubble chart where bubbles are added/removed based on selected filters. Eventually the data is going to be called from my server through Ajax, but currently I just have a set array of objects that i add to or remove from with buttons.
I have two problems that i cannot figure out at the moment.
When I click the Add Bubble button a bubble is added but the it doesn't appear to be added to the force simulation?
When i click the Remove Bubble button the bubble with the data that is removed isn't being removed with exit.remove()
The full code can be found here https://jsfiddle.net/codered1988/s2z63crL/
function drawBubbles() {
let root = d3.hierarchy({ children: data })
.sum(d => d.value);
let nodes = pack(root).leaves().map(node => {
const data = node.data;
return {
x: centerX + (node.x - centerX) * 3,
y: centerY + (node.y - centerY) * 3,
r: 10, // for tweening
radius: node.r, //original radius
id: data.id,
type: data.type,
name: data.name,
value: data.value,
}
});
simulation.nodes(nodes).on('tick', ticked);
node = svg.selectAll('.node')
.data(nodes, d => d.id)
.join(
function(n){ // enter
enter = n.append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.2).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}))
// Create Circles
enter.append('circle')
.attr('id', d => d.id)
.attr('r', d => d.radius)
//.style('fill', d => scaleColor(d.type))
.style('fill', d => fill(d.type))
.attr("stroke", "black")
.style("stroke-width", 2)
.transition().duration(2000).ease(d3.easeElasticOut)
.tween('circleIn', (d) => {
console.log('tween');
let i = d3.interpolateNumber(d.r, d.radius);
return (t) => {
d.r = i(t);
simulation.force('collide', forceCollide);
}
})
enter.append('clipPath')
.attr('id', d => `clip-${d.id}`)
.append('use')
.attr('xlink:href', d => `#${d.id}`);
// display text as circle icon
enter.filter(d => !String(d.name).includes('img/'))
.append('text')
.classed('node-icon', true)
.attr('clip-path', d => `url(#clip-${d.id})`)
.selectAll('tspan')
.data(d => d.name.split(';'))
.enter()
.append('tspan')
.attr('x', 0)
.attr('y',(d, i, nodes) => (13 + (i - nodes.length / 2 - 0.5) * 10))
.text(name => name);
return enter;
},
update => update
.attr("fill", "black")
.attr("y", 0)
.call(update => update.transition(t)
.attr("x", (d, i) => i * 16)),
exit => exit
.attr("fill", "brown")
.call(exit => exit.transition(t)
.attr("y", 30)
.remove())
);
//simulation.nodes(nodes).alpha(1).restart();
}
I'm trying to move from visjs to d3js cause visjs always redraw the same data in a different way. The problem I faced is that d3js draw my nodes too close to each other, so labels overlay each others(see screenshot).
D3js is not an easy tool for beginners, but I'm sure there is some way to fix this problem. Please help to solve it.
const links = edges.map((edge) => ({source: edge.from, target: edge.to}));
function getNodeColor(node) {
return node.color.background;
}
const width = 1000;
const height = 800;
const svg = d3.select('svg');
svg.selectAll('*').remove();
svg.attr('width', width).attr('height', height);
// simulation setup with all forces
const linkForce = d3
.forceLink()
.id(function (link) {
return (link as NetworkNode).id;
})
.strength(function (link) {
return 1;
});
const simulation = d3
.forceSimulation()
.force('link', linkForce)
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2));
const linkElements = svg
.append('g')
.attr('class', 'links')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke-width', 1)
.attr('stroke', 'rgba(50, 50, 50, 0.2)');
const nodeElements = svg
.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(nodes)
.enter()
.append('circle')
.attr('r', 10)
.attr('fill', getNodeColor);
const textElements = svg
.append('g')
.attr('class', 'texts')
.selectAll('text')
.data(nodes)
.enter()
.append('text')
.text(function (node) {
return node.label;
})
.attr('font-size', 15)
.attr('dx', 15)
.attr('dy', 4);
simulation.nodes(nodes).on('tick', () => {
nodeElements
.attr('cx', function (node) {
return node.x;
})
.attr('cy', function (node) {
return node.y;
});
textElements
.attr('x', function (node) {
return node.x;
})
.attr('y', function (node) {
return node.y;
});
linkElements
.attr('x1', function (link) {
return (link as d3.SimulationLinkDatum<any>).source.x;
})
.attr('y1', function (link) {
return (link as d3.SimulationLinkDatum<any>).source.y;
})
.attr('x2', function (link) {
return (link as d3.SimulationLinkDatum<any>).target.x;
})
.attr('y2', function (link) {
return (link as d3.SimulationLinkDatum<any>).target.y;
});
});
// #ts-ignore
simulation.force('link').links(links);
After following up from this question Insert text inside Circle in D3 chart
My nodes are sticking to the center. I am not sure which property is directing my nodes and their x and y coordinates. I recently chnaged my code to add a g layer to the circles so that i can append text along with shape.
DATA
https://api.myjson.com/bins/hwtj0
UPDATED CODE
async function d3function() {
d3.selectAll("svg > *").remove();
const svg = d3.select("svg");
file = document.getElementById("selectFile").value;
console.log("File: " + file)
var width = 900
var height = 900
svg.style("width", width + 'px').style("height", height + 'px');
data = (await fetch(file)).json()
d3.json(file).then(function(data) {
const links = data.links.map(d => Object.create(d));
const nodes = data.nodes.map(d => Object.create(d));
console.log(links.length);
console.log(nodes.length);
const simulation = forceSimulation(nodes, links).on("tick", ticked);
var categorical = [
{ "name" : "schemeAccent", "n": 8},
{ "name" : "schemeDark2", "n": 8},
]
// var colorScale = d3.scaleOrdinal(d3[categorical[6].name])
var color = d3.scaleOrdinal(d3[categorical[1].name]);
var drag = simulation => {
function dragstarted(d) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function dragended(d) {
if (!d3.event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
const link = svg.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.enter().append("line")
.attr("stroke-width", d => Math.sqrt(d.value));
// link.append("title").text(d => d.value);
// var circles = svg.append("g")
// .attr("stroke", "#fff")
// .attr("stroke-width", 1.5)
// .selectAll(".circle")
// .data(nodes)
// const node = circles.enter().append("circle")
// .attr("r", 5)
// .attr("fill", d => color(d.group))
// .call(drag(simulation));
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll("circles")
.data(nodes)
.enter()
.append("g")
.classed('circles', true)
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
node.append("circle")
.classed('circle', true)
.attr("r", 5)
.attr("fill", d => color(d.group))
.call(drag(simulation));
node
.append("text")
.classed('circleText', true)
.attr('dy', '0.35em')
.attr('dx', 5)
.text(d => "Node: " + d.id);
node.append("title").text(d => "Node: " + d.id);
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
.attr("cx", d => d.x)
.attr("cy", d => d.y);
}
});
}
function forceSimulation(nodes, links) {
return d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter());
}
UPDATED OUTPUT
EXPECTED OUTPUT
UPDATED HTML
<g stroke="#fff" stroke-width="1.5">
<g class="circle" cx="-35.89111508769784" cy="131.13965804447696">
<circle class="circle" r="5" fill="#1b9e77"></circle>
<text class="circleText" dy="0.35em" dx="5">Node: 0</text>
<title>Node: 0</title>
</g>
<g class="circle" cx="70.97799024729613" cy="-195.71408429254427">
<circle class="circle" r="5" fill="#d95f02"></circle>
<text class="circleText" dy="0.35em" dx="5">Node: 3</text>
<title>Node: 3</title>
</g>
[....]
</g>
You have to adapt your code slightly as it currently assumes that you're working with circle elements, where you specify the centres using cx and cy, but you are now using g elements, which use standard x and y coordinates.
First, remove the transform from the g element (that's a leftover from my demo code):
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll(".circles") // note - should be .circles!
.data(nodes)
.enter()
.append("g")
.classed('circles', true)
and in the ticked() function, change the node updating code into a transform that works on g elements (which don't have cx or cy):
node.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')' )
Demo:
var json = {"nodes":[{"id":"0","group":0},{"id":"1","group":1},{"id":"2","group":2},{"id":"3","group":3},{"id":"4","group":4},{"id":"5","group":5},{"id":"6","group":6},{"id":"7","group":7},{"id":"8","group":8},{"id":"9","group":9},{"id":"10","group":10},{"id":"11","group":11},{"id":"12","group":12},{"id":"13","group":13},{"id":"14","group":14},{"id":"15","group":15},{"id":"16","group":16},{"id":"17","group":17},{"id":"18","group":18},{"id":"19","group":19}],"links":[{"source":"0","target":"1","value":1},{"source":"0","target":"18","value":1},{"source":"0","target":"10","value":1},{"source":"0","target":"12","value":1},{"source":"0","target":"5","value":1},{"source":"0","target":"8","value":1},{"source":"1","target":"0","value":1},{"source":"1","target":"9","value":1},{"source":"1","target":"4","value":1},{"source":"2","target":"4","value":1},{"source":"2","target":"17","value":1},{"source":"2","target":"13","value":1},{"source":"2","target":"15","value":1},{"source":"3","target":"6","value":1},{"source":"4","target":"14","value":1},{"source":"4","target":"2","value":1},{"source":"4","target":"5","value":1},{"source":"4","target":"19","value":1},{"source":"4","target":"1","value":1},{"source":"5","target":"4","value":1},{"source":"5","target":"0","value":1},{"source":"6","target":"3","value":1},{"source":"7","target":"18","value":1},{"source":"7","target":"16","value":1},{"source":"8","target":"0","value":1},{"source":"9","target":"1","value":1},{"source":"10","target":"0","value":1},{"source":"10","target":"15","value":1},{"source":"12","target":"0","value":1},{"source":"13","target":"15","value":1},{"source":"13","target":"2","value":1},{"source":"14","target":"4","value":1},{"source":"15","target":"13","value":1},{"source":"15","target":"10","value":1},{"source":"15","target":"2","value":1},{"source":"16","target":"7","value":1},{"source":"17","target":"2","value":1},{"source":"18","target":"0","value":1},{"source":"18","target":"7","value":1},{"source":"19","target":"4","value":1},{"source":"19","target":"4","value":1}]};
d3.selectAll("svg > *").remove();
const svg = d3.select("svg");
var width = 900
var height = 900
svg.style("width", width + 'px').style("height", height + 'px');
const links = json.links.map(d => Object.create(d));
const nodes = json.nodes.map(d => Object.create(d));
const simulation = forceSimulation(nodes, links).on("tick", ticked);
var categorical = [
{
"name": "schemeAccent",
"n": 8
},
{
"name": "schemeDark2",
"n": 8
}, ]
var color = d3.scaleOrdinal(d3[categorical[1].name]);
var drag = simulation => {
function dragstarted(d) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function dragended(d) {
if (!d3.event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
const link = svg.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.enter().append("line")
.attr("stroke-width", d => Math.sqrt(d.value));
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll(".circles")
.data(nodes)
.enter()
.append("g")
.classed('circles', true)
.call(drag(simulation))
// .attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
const circle = node.append("circle")
.classed('circle', true)
.attr("r", 5)
.attr("fill", d => color(d.group))
node
.append("text")
.classed('circleText', true)
.attr('dy', '0.35em')
.attr('dx', 5)
.text(d => "Node: " + d.id);
node.append("title").text(d => "Node: " + d.id);
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.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')')
}
function forceSimulation(nodes, links) {
return d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter());
}
.circleText { fill: black; stroke: none }
<script src="//d3js.org/d3.v5.js"></script>
<svg></svg>
I have a force directed graph where each node is a group and contains a foreignObject and an img inside it. x and y coordiantes are applied to the img tag but I would like to have them on the foreignObject or g. I am not sure how this can be achieved.
I tried setting x and y coordinates to the foreignObject but they are always returned as NaN.
const plot = (data) => {
data.nodes = data.nodes.map((d, index) => {
d['id'] = index;
return d;
});
const margin = {
top: 20,
right: 20,
bottom: 10,
left: 100
};
const width = Math.max((((window.innerWidth / 100) * 80) - margin.right - margin.left), 700);
const height = ((window.innerHeight / 100) * 80) - margin.bottom - margin.top;
const svg = d3.select('svg')
.attr('width', width + margin.left + margin.right + 100)
.attr('height', height + margin.top + margin.bottom + 100)
.append('g')
.attr('transform',
`translate(${margin.left}, ${margin.top})`);
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(function (d) { return d.id; }).distance(100).strength(1))
.force('charge', d3.forceManyBody())
.force('center', d3.forceCenter(width / 2, height / 2));
const dragstarted = d => {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
const dragged = d => {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
const dragended = d => {
if (!d3.event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
const 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
.attr("x", function (d) { return d.x = Math.max(5, Math.min(width - 5, d.x)); })
.attr("y", function (d) { return d.y = Math.max(5, Math.min(height - 5, d.y)); });
}
const link = svg.append('g')
.attr('class', 'links')
.selectAll('line')
.data(data.links)
.enter().append('line')
.attr('stroke-width', function (d) { return Math.sqrt(d.value); });
const node = svg.append('g')
.attr('class', 'nodes')
.selectAll('.node-group')
.data(data.nodes)
.enter()
.append('g')
.append('foreignObject')
.attr('class', 'node-group')
.attr('width', '10')
.attr('height', '10')
.insert('xhtml:img')
.attr('src', 'flags/blank.png')
.attr('class', d => `flag flag-${d.code}`)
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append("title")
.text(function (d) { return d.country; })
.exit();
simulation
.nodes(data.nodes)
.on("tick", ticked);
simulation.force("link")
.links(data.links);
}
If you look at your node selection...
const node = svg.append('g')
.attr('class', 'nodes')
.selectAll('.node-group')
.data(data.nodes)
.enter()
.append('g')
.append('foreignObject')
.attr('class', 'node-group')
.attr('width', '10')
.attr('height', '10')
.insert('xhtml:img')
.attr('src', 'flags/blank.png')
.attr('class', d => `flag flag-${d.code}`)
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
... you'll see that it is a selection with the images. Since you want to apply the x and y properties to the foreignObject, just break it:
const node = svg.append('g')
.attr('class', 'nodes')
.selectAll('.node-group')
.data(data.nodes)
.enter()
.append('g')
.append('foreignObject')
.attr('class', 'node-group')
.attr('width', '10')
.attr('height', '10');
node.insert('xhtml:img')
.attr('src', 'flags/blank.png')
.attr('class', d => `flag flag-${d.code}`)
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
That way, node is a selection with the foreignObjects.