I struggle to properly redraw the provided D3 forced graph. As soon as I delete a node, which is NOT the last node, the binding/assignment between link and node is broken. The belonged linktext is either wrong or written on top of an existing.
What I am doing?
On node click I retrieve the node id, which I use to retrieve the correct link array index, by comparing them the source.id and to find the correct node array index position too. Further I use the results to finally splice the link and node array at the correct position.
So whats my problem?
For example I delete node number 3, which should delete the node array object at position 2, which is true. Further the link between 3 --- 1 should be removed from the link array object at position 1 as well, which is also true.
At the end I restart the data assignment and call the restart() function. Which should use the modified nodes and links array´s to merge and redraw the graph. It actually does redraw but the link text is wrong.. instead of node 3 node 5 was deleted.
Help.
var data = {
"nodes": [{
"id": 1
},
{
"id": 2,
},
{
"id": 3,
},
{
"id": 4,
},
{
"id": 5,
}
],
"links": [{
"source": 2,
"target": 1,
"text": "2 --- 1"
},
{
"source": 3,
"target": 1,
"text": "3 --- 1"
},
{
"source": 4,
"target": 1,
"text": "4 --- 1"
},
{
"source": 5,
"target": 1,
"text": "5 --- 1"
}
]
};
let nodes = data.nodes
let links = data.links
//Helper
let nodeToDelete
var width = window.innerWidth,
height = window.innerHeight;
var buttons = d3.select("body").selectAll("button")
.data(["add node", "remove node"])
.enter()
.append("button")
.attr("class", "buttons")
.text(function (d) {
return d;
})
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
var simulation = d3.forceSimulation()
.force("size", d3.forceCenter(width / 2, height / 2))
.force("charge", d3.forceManyBody().strength(-5000))
.force("link", d3.forceLink().id(function (d) {
return d.id
}).distance(250))
linksContainer = svg.append("g").attr("class", "linkscontainer")
nodesContainer = svg.append("g").attr("class", "nodesContainer")
console.log("links_on_init", links)
console.log("nodes_on_init", nodes)
restart()
simulation
.nodes(nodes)
.on("tick", tick)
simulation
.force("link").links(links)
function tick() {
linkLine.attr("d", function (d) {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy)
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
})
node
.attr("transform", d => `translate(${d.x}, ${d.y})`);
}
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
buttons.on("click", function (_, d) {
if (d === "add node") {
const newObj = { "id": nodes.length + 1,}
const newLink = {"source": nodes.length + 1, "target": 1, "text": nodes.length + 1 + " --- " + "1"}
nodes.push(newObj)
links.push(newLink)
} else if (d === "remove node") {
if (nodeToDelete != undefined) {
let linkToDeleteIndex = links.findIndex(obj => obj.source.id === nodeToDelete.id )
let nodeToDeleteIndex = nodes.findIndex(obj => obj.id === nodeToDelete.id)
links.splice(linkToDeleteIndex, 1)
nodes.splice(nodeToDeleteIndex, 1)
console.log("links_after_removal", links)
console.log("nodes_after_removal", nodes)
}
}
restart()
})
function restart() {
// Update linkLines
linkLine = linksContainer.selectAll(".linkPath")
.data(links)
linkLine.exit().remove()
const linkLineEnter = linkLine.enter()
.append("path")
.attr("class", "linkPath")
.attr("stroke", "red")
.attr("fill", "transparent")
.attr("stroke-width", 3)
.attr("id", function (_, i) {
return "path" + i
})
linkLine = linkLineEnter.merge(linkLine)
// Update linkText
linkText = linksContainer.selectAll("linkLabel")
.data(links)
linkText.exit().remove()
const linkTextEnter = linkText.enter()
.append("text")
.attr("dy", -10)
.attr("class", "linkLabel")
.attr("id", function (d, i) { return "linkLabel" + i })
.attr("text-anchor", "middle")
.text("")
linkTextEnter.append("textPath")
.attr("xlink:href", function (_, i) {
return "#path" + i
})
.attr("startOffset", "50%")
.attr("opacity", 0.75)
.attr("cursor", "default")
.attr("class", "linkText")
.attr("color", "black")
.text(function (d) {
return d.text
})
linkText = linkTextEnter.merge(linkText)
// Update nodes
node = nodesContainer.selectAll(".nodes")
.data(nodes)
node.exit().remove()
const nodesEnter = node.enter()
.append("g")
.attr("class", "nodes")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
nodesEnter.selectAll("circle")
.data(d => [d])
.enter()
.append("circle")
.style("stroke", "blue")
.attr("r", 40)
.on("click", function(_, d) {
d3.selectAll("circle")
.attr("fill", "whitesmoke")
d3.select(this)
.attr("fill", "red")
nodeToDelete = d
})
nodesEnter.append("text")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 20)
.attr("fill", "black")
.attr("pointer-events", "none")
.text(function (d) {
return d.id
})
node = nodesEnter.merge(node)
// Update and restart the simulation.
simulation
.nodes(nodes);
simulation
.force("link")
.links(links)
simulation.restart().alpha(1)
}
.link {
stroke: #000;
stroke-width: 1.5px;
}
.nodes {
fill: whitesmoke;
}
.buttons {
margin: 0 1em 0 0;
}
<!DOCTYPE html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0">
<meta charset="utf-8">
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.3.js"></script>
<!-- D3 -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- fontawesome stylesheet https://fontawesome.com/ -->
<script src="https://kit.fontawesome.com/98a5e27706.js" crossorigin="anonymous"></script>
</head>
<body>
</body>
</html>
You should provide data keys when you do .data(data). So for example when you provide data to the nodes, you may pass key like this:
node = nodesContainer.selectAll(".nodes")
.data(nodes, node => node.id) // Pass node.id as a key so d3 knows which node is a new and which one is old.
You can read about this here - https://observablehq.com/#dnarvaez27/understanding-enter-exit-merge-key-function
Also you've forgot to place . before class name when you adding text labels to the graph, that is why text was duplicated )
Here fixed example, but since you are using array length as ids, there will be duplicates in ids for nodes and links, it will lead to unexpected results, I consider to use uniq ids rather then indexes.
var data = {
"nodes": [{
"id": 1
},
{
"id": 2,
},
{
"id": 3,
},
{
"id": 4,
},
{
"id": 5,
}
],
"links": [{
"source": 2,
"target": 1,
"text": "2 --- 1"
},
{
"source": 3,
"target": 1,
"text": "3 --- 1"
},
{
"source": 4,
"target": 1,
"text": "4 --- 1"
},
{
"source": 5,
"target": 1,
"text": "5 --- 1"
}
]
};
let nodes = data.nodes
let links = data.links
//Helper
let nodeToDelete
var width = window.innerWidth,
height = window.innerHeight;
var buttons = d3.select("body").selectAll("button")
.data(["add node", "remove node"])
.enter()
.append("button")
.attr("class", "buttons")
.text(function (d) {
return d;
})
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
var simulation = d3.forceSimulation()
.force("size", d3.forceCenter(width / 2, height / 2))
.force("charge", d3.forceManyBody().strength(-5000))
.force("link", d3.forceLink().id(function (d) {
return d.id
}).distance(250))
linksContainer = svg.append("g").attr("class", "linkscontainer")
nodesContainer = svg.append("g").attr("class", "nodesContainer")
console.log("links_on_init", links)
console.log("nodes_on_init", nodes)
restart()
simulation
.nodes(nodes)
.on("tick", tick)
simulation
.force("link").links(links)
function tick() {
linkLine.attr("d", function (d) {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy)
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
})
node
.attr("transform", d => `translate(${d.x}, ${d.y})`);
}
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
buttons.on("click", function (_, d) {
if (d === "add node") {
const newObj = { "id": nodes.length + 1,}
const newLink = {"source": nodes.length + 1, "target": 1, "text": nodes.length + 1 + " --- " + "1"}
nodes.push(newObj)
links.push(newLink)
} else if (d === "remove node") {
if (nodeToDelete != undefined) {
let linkToDeleteIndex = links.findIndex(obj => obj.source.id === nodeToDelete.id )
let nodeToDeleteIndex = nodes.findIndex(obj => obj.id === nodeToDelete.id)
links.splice(linkToDeleteIndex, 1)
nodes.splice(nodeToDeleteIndex, 1)
console.log("links_after_removal", links)
console.log("nodes_after_removal", nodes)
}
}
restart()
})
function restart() {
// Update linkLines
linkLine = linksContainer.selectAll(".linkPath")
.data(links, link => link.text) // ADD DATA KEY FOR LINK
linkLine.exit().remove()
const linkLineEnter = linkLine.enter()
.append("path")
.attr("class", "linkPath")
.attr("stroke", "red")
.attr("fill", "transparent")
.attr("stroke-width", 3)
.attr("id", function (_, i) {
return "path" + i
})
linkLine = linkLineEnter.merge(linkLine)
// Update linkText
linkText = linksContainer.selectAll(".linkLabel") // FIXED ClassName
.data(links, link => link.text) // ADD DATA KEY FOR TEXT
linkText.exit().remove()
const linkTextEnter = linkText.enter()
.append("text")
.attr("dy", -10)
.attr("class", "linkLabel")
.attr("id", function (d, i) { return "linkLabel" + i })
.attr("text-anchor", "middle")
.text("")
linkTextEnter.append("textPath")
.attr("xlink:href", function (_, i) {
return "#path" + i
})
.attr("startOffset", "50%")
.attr("opacity", 0.75)
.attr("cursor", "default")
.attr("class", "linkText")
.attr("color", "black")
.text(function (d) {
return d.text
})
linkText = linkTextEnter.merge(linkText)
// Update nodes
node = nodesContainer.selectAll(".nodes")
.data(nodes, node => node.id) // ADD DATA KEY FOR NODE
node.exit().remove()
const nodesEnter = node.enter()
.append("g")
.attr("class", "nodes")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
nodesEnter.selectAll("circle")
.data(d => [d])
.enter()
.append("circle")
.style("stroke", "blue")
.attr("r", 40)
.on("click", function(_, d) {
d3.selectAll("circle")
.attr("fill", "whitesmoke")
d3.select(this)
.attr("fill", "red")
nodeToDelete = d
})
nodesEnter.append("text")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 20)
.attr("fill", "black")
.attr("pointer-events", "none")
.text(function (d) {
return d.id
})
node = nodesEnter.merge(node)
// Update and restart the simulation.
simulation
.nodes(nodes);
simulation
.force("link")
.links(links)
simulation.restart().alpha(1)
}
.link {
stroke: #000;
stroke-width: 1.5px;
}
.nodes {
fill: whitesmoke;
}
.buttons {
margin: 0 1em 0 0;
}
<!DOCTYPE html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0">
<meta charset="utf-8">
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.3.js"></script>
<!-- D3 -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- fontawesome stylesheet https://fontawesome.com/ -->
<script src="https://kit.fontawesome.com/98a5e27706.js" crossorigin="anonymous"></script>
</head>
<body>
</body>
</html>
Related
I am pretty confused regarding the following behavior. The D3v3 forced graph below shows 4 nodes, with at least one link to another node. I dont know why, but the distance between the nodes is broken as I got more than 3 nodes. Even if distance and charge are set.
var width = window.innerWidth,
height = window.innerHeight;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height)
.call(d3.behavior.zoom().on("zoom", function() {
svg.attr("transform", "translate(" + d3.event.translate + ")" + "scale(" + d3.event.scale + ")")
}))
.append("g")
var defs = svg.append("defs")
const arc = d3.svg.arc()
.innerRadius(80 + 10)
.outerRadius(80 + 10)
.startAngle(-Math.PI)
.endAngle(Math.PI)
defs.append("path")
.attr("id", "curvedLabelPath")
.attr("d", arc())
////////////////////////
// outer force layout
var outerData = {
"nodes": [
{ "id": "A" },
{ "id": "B" },
{ "id": "C" },
{ "id": "D" }
],
"links": [
{ "source": 0, "target": 1},
{ "source": 1, "target": 2},
{ "source": 2, "target": 0},
{ "source": 3, "target": 0}
]
};
var outerLayout = d3.layout.force()
.size([width, height])
.charge(-1000)
.gravity(0.85)
.distance(500)
.links(outerData.links)
.nodes(outerData.nodes)
.start();
var outerLinks = svg.selectAll("g")
.data(outerData.links)
.enter().append("g")
.attr("cursor", "pointer")
//.attr("class", "g.outerLink")
var outerLine = outerLinks
.append("path")
.attr("id", function (_, i) {
return "path" + i
})
.attr("stroke", "black")
.attr("opacity", 0.75)
.attr("stroke-width", 3)
.attr("fill", "transparent")
.attr("cursor", "default")
var outerNodes = svg.selectAll("g.outer")
.data(outerData.nodes, function (d) { return d.id; })
.enter()
.append("g")
.attr("class", "outer")
.attr("id", function (d) { return d.id; })
.call(outerLayout.drag()
.on("dragstart", function () {
d3.event.sourceEvent.stopPropagation();
})
)
.attr("cursor", "pointer")
outerNodes
.append("circle")
.style("fill", "whitesmoke")
.style("stroke", "black")
.style("stroke-width", 3)
.attr("r", 80);
outerNodes
.append("text")
.append("textPath")
.attr("href", "#curvedLabelPath")
.attr("text-anchor", "middle")
.attr("startOffset", "25%")
.attr("font-size", "30px")
.attr("cursor", "pointer")
.text(function (d) {
return d.id
})
outerLayout
.on("tick", function() {
outerLine.attr("d", function (d) {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
});
outerNodes
.attr("transform", function (d) { return "translate(" + d.x + "," + d.y + ")"; });
})
body {
background: whitesmoke,´;
overflow: hidden;
margin: 0px;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>D3v3 forces</title>
<!-- d3.js framework -->
<script src="https://d3js.org/d3.v3.js"></script>
</head>
</html>
I am aware force setup is different in v4 and higher, still regarding my opinion some features are handled better in v3. I would be glad if an D3 expert could either help or explain those behaviour.
Problem: The Gooey effect applies to the links too. Which creates a teardrop shaped frame instead of an circle.
The snipped contains a dragged() function which allows the user to tear off node 1 from node 0. Further it is possible to connect node 1 with node 0 again with the help of dragging. The code isn´t clean at all, as its a playground only.
Goal: How can I exclude the links from the Gooey effect in a way, that all links are displayed correctly and still achieve a proper circled shape. The shape of the Gooey effect can be manipulated by changing the -5 to -40, unfortunately it will hide the links completely:
.attr("values", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 50 -5")
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>D3v6 Playground</title>
<!-- call external d3.js framework -->
<script src="https://d3js.org/d3.v6.js"></script>
</head>
<style>
body {
overflow: hidden;
margin: 0px;
}
.canvas {
background-color: rgb(220, 220, 220);
}
.node {
cursor: pointer;
}
.node:hover {
stroke: red
}
.link {
fill: none;
cursor: default;
stroke: rgb(0, 0, 0);
stroke-width: 3px;
}
</style>
<body>
<svg id="svg"> </svg>
<script>
var graph = {
"nodes": [
{
"id": 0,
},
{
"id": 1,
},
{
"id": 2,
}
],
"links": [
{
"source": 1,
"target": 0,
},
{
"source": 2,
"target": 0,
},
]
}
var width = window.innerWidth
var height = window.innerHeight
var svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
.style("filter", "url(#gooey)")
// remove zoom on dblclick listener
d3.select("svg").on("dblclick.zoom", null)
var linkContainer = svg.append("g").attr("class", "linkContainer")
var nodeContainer = svg.append("g").attr("class", "nodeContainer")
var isSpliced = false;
var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) {
return d.id;
}).distance(150))
.force("charge", d3.forceManyBody().strength(-50))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(30))
//###############################################
//######### SVG Filter for Gooey effect #########
//###############################################
var defs = svg.append("defs");
var filter = defs.append("filter").attr("id", "gooey");
filter.append("feGaussianBlur")
.attr("in", "SourceGraphic")
.attr("stdDeviation", "10")
.attr("result", "blur");
filter.append("feColorMatrix")
.attr("in", "blur")
.attr("mode", "matrix")
.attr("values", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 50 -5")
.attr("result", "gooey");
filter.append("feComposite")
.attr("in", "SourceGraphic")
.attr("in2", "gooey")
.attr("operator", "atop");
initialize()
//###############################################
//############## Initialization #################
//###############################################
function initialize() {
link = linkContainer.selectAll(".link")
.data(graph.links)
.join("line")
.attr("class", "link")
node = nodeContainer.selectAll(".node")
.data(graph.nodes, d => d.id)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
node.selectAll("circle")
.data(d => [d])
.join("circle")
.attr("r", 30)
.style("fill", "white")
node.selectAll("text")
.data(d => [d])
.join("text")
.style("class", "icon")
.attr("font-family", "FontAwesome")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 30)
.attr("fill", "black")
.attr("stroke-width", "0px")
.attr("pointer-events", "none")
.text((d) => {
return d.id
})
simulation
.nodes(graph.nodes)
.on("tick", ticked);
simulation
.force("link")
.links(graph.links)
}
//###############################################
//############# Update Positions ################
//###############################################
function ticked() {
// update link positions
link
.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
// update node positions
node
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
}
//###############################################
//################ Drag Nodes ###################
//###############################################
var lineX;
var lineY;
var isPlugged = true;
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
lineX = d.x
lineY = d.y
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
var root = graph.nodes.find(element => (element.id === 0))
var distance = (root.x - d.x) * 2 + (root.y - d.y) * 2
if (isPlugged && d.id === 1) {
var indexOfLink = graph.links.findIndex(element => (element.source.id === d.id))
if (distance < 0) {
distance = -distance
}
if (distance > 1000) {
graph.links.splice(indexOfLink, 1)
isPlugged = false;
}
} else {
if (distance < 0) {
distance = -distance
}
if (distance < 20) {
graph.links.push({ source: d.id, target: root.id })
isPlugged = true;
}
}
initialize()
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
link.filter((a) => {
return a.source.id === d.id
}).style("stroke", "black")
}
</script>
</body>
</html>
Just apply the style to nodeContainer instead of svg - see comments below:
var svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
// <--------- remove the style here
// remove zoom on dblclick listener
d3.select("svg").on("dblclick.zoom", null)
var linkContainer = svg.append("g").attr("class", "linkContainer")
var nodeContainer = svg.append("g").attr("class", "nodeContainer")
.style("filter", "url(#gooey)") // <---------- add the style here
Your code updated:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>D3v6 Playground</title>
<!-- call external d3.js framework -->
<script src="https://d3js.org/d3.v6.js"></script>
</head>
<style>
body {
overflow: hidden;
margin: 0px;
}
.canvas {
background-color: rgb(220, 220, 220);
}
.node {
cursor: pointer;
}
.node:hover {
stroke: red
}
.link {
fill: none;
cursor: default;
stroke: rgb(0, 0, 0);
stroke-width: 3px;
}
</style>
<body>
<svg id="svg"> </svg>
<script>
var graph = {
"nodes": [
{
"id": 0,
},
{
"id": 1,
},
{
"id": 2,
}
],
"links": [
{
"source": 1,
"target": 0,
},
{
"source": 2,
"target": 0,
},
]
}
var width = window.innerWidth
var height = window.innerHeight
var svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
// remove zoom on dblclick listener
d3.select("svg").on("dblclick.zoom", null)
var linkContainer = svg.append("g").attr("class", "linkContainer")
var nodeContainer = svg.append("g").attr("class", "nodeContainer")
.style("filter", "url(#gooey)")
var isSpliced = false;
var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) {
return d.id;
}).distance(150))
.force("charge", d3.forceManyBody().strength(-50))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(30))
//###############################################
//######### SVG Filter for Gooey effect #########
//###############################################
var defs = svg.append("defs");
var filter = defs.append("filter").attr("id", "gooey");
filter.append("feGaussianBlur")
.attr("in", "SourceGraphic")
.attr("stdDeviation", "10")
.attr("result", "blur");
filter.append("feColorMatrix")
.attr("in", "blur")
.attr("mode", "matrix")
.attr("values", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 50 -5")
.attr("result", "gooey");
filter.append("feComposite")
.attr("in", "SourceGraphic")
.attr("in2", "gooey")
.attr("operator", "atop");
initialize()
//###############################################
//############## Initialization #################
//###############################################
function initialize() {
link = linkContainer.selectAll(".link")
.data(graph.links)
.join("line")
.attr("class", "link")
node = nodeContainer.selectAll(".node")
.data(graph.nodes, d => d.id)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
node.selectAll("circle")
.data(d => [d])
.join("circle")
.attr("r", 30)
.style("fill", "white")
node.selectAll("text")
.data(d => [d])
.join("text")
.style("class", "icon")
.attr("font-family", "FontAwesome")
.attr("dominant-baseline", "central")
.attr("text-anchor", "middle")
.attr("font-size", 30)
.attr("fill", "black")
.attr("stroke-width", "0px")
.attr("pointer-events", "none")
.text((d) => {
return d.id
})
simulation
.nodes(graph.nodes)
.on("tick", ticked);
simulation
.force("link")
.links(graph.links)
}
//###############################################
//############# Update Positions ################
//###############################################
function ticked() {
// update link positions
link
.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
// update node positions
node
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
}
//###############################################
//################ Drag Nodes ###################
//###############################################
var lineX;
var lineY;
var isPlugged = true;
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
lineX = d.x
lineY = d.y
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
var root = graph.nodes.find(element => (element.id === 0))
var distance = (root.x - d.x) * 2 + (root.y - d.y) * 2
if (isPlugged && d.id === 1) {
var indexOfLink = graph.links.findIndex(element => (element.source.id === d.id))
if (distance < 0) {
distance = -distance
}
if (distance > 1000) {
graph.links.splice(indexOfLink, 1)
isPlugged = false;
}
} else {
if (distance < 0) {
distance = -distance
}
if (distance < 20) {
graph.links.push({ source: d.id, target: root.id })
isPlugged = true;
}
}
initialize()
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
link.filter((a) => {
return a.source.id === d.id
}).style("stroke", "black")
}
</script>
</body>
</html>
Recently I migrated my graph to v6 where I replaced the enter(), remove(), exit() pattern with join(). Unfortunately, I can´t change the link text any longer. Usually, the text should switch between "blue" and "green" as soon as a link was clicked.
I know, that I append a new text object instead of updating the existing value. Which is the problem. It's visible in the browser inspector. But I can´t figure out how to adapt the code, as join() is not working at this point.
Any hints?
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>D3 JOIN Test</title>
<!-- call external d3.js framework -->
<script src="https://d3js.org/d3.v6.js"></script>
<!-- import multiselection framework -->
<script src="https://d3js.org/d3-selection-multi.v1.js"></script>
</head>
<style>
body {
overflow: hidden;
margin: 0px;
}
.canvas {
background-color: rgb(220, 220, 220);
}
.link {
cursor: pointer;
stroke: rgb(0, 0, 0);
stroke-width: 4px;
}
.link:hover {
stroke: red;
}
</style>
<body>
<svg id="svg"> </svg>
<script>
var graph = {
"nodes": [
{
"id": 1,
},
{
"id": 2,
},
{
"id": 3,
}
],
"links": [
{
"source": 1,
"target": 2,
"type": "blue"
},
{
"source": 2,
"target": 3,
"type": "blue"
},
{
"source": 3,
"target": 1,
"type": "blue"
}
]
}
// declare initial variables
var svg = d3.select("svg")
width = window.innerWidth
height = window.innerHeight
// define cavnas area to draw everything
svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
// remove zoom on dblclick listener
d3.select("svg").on("dblclick.zoom", null)
var linksContainer = svg.append("g").attr("class", "linksContainer")
var nodesContainer = svg.append("g").attr("class", "nodesContainer")
// iniital force simulation
var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) {
return d.id;
}).distance(200))
.force("charge", d3.forceManyBody().strength(-100))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("attraceForce", d3.forceManyBody().strength(70));
initialze()
function initialze() {
links = linksContainer.selectAll(".link")
.data(graph.links)
.join("line")
.attr("class", "link")
.on("click", click)
linkPaths = linksContainer.selectAll(".linkPath")
.data(graph.links)
.join("path")
.style("pointer-events", "none")
.attrs({
"class": "linkPath",
"fill-opacity": 1,
"stroke-opacity": 1,
"id": function (d, i) { return "linkPath" + i }
})
.style("display", "block")
linkLabels = linksContainer.selectAll(".linkLabel")
.data(graph.links)
.join("text")
.style("pointer-events", "none")
.attrs({
"class": "linkLabel",
"id": function (d, i) { return "linkLabel" + i },
"font-size": 16,
"fill": "black"
})
.style("display", "block")
linkLabels
.append("textPath")
.attr('xlink:href', function (d, i) { return '#linkPath' + i })
.style("text-anchor", "middle")
.style("pointer-events", "none")
.attr("startOffset", "50%")
.text(function (d) { return d.type })
nodes = nodesContainer.selectAll(".node")
.data(graph.nodes, d => d.id)
.join("circle")
.attr("class", "node")
.attr("r", 30)
.attr("fill", "whitesmoke")
.attr("stroke", "white")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
simulation
.nodes(graph.nodes)
.on("tick", ticked);
simulation
.force("link")
.links(graph.links)
}
function mouseEnter(event, d) {
d3.select(this).style("fill", "lightblue")
}
function mouseLeave(event, d) {
d3.select(this).style("fill", "whitesmoke")
}
function click(event, d) {
if (d.type == "blue") {
d.type = "green"
} else if (d.type == "green") {
d.tyoe = "blue"
}
initialze()
}
function close() {
contextMenuLink.classList.remove("active")
}
function ticked() {
links
.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
nodes
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
linkPaths.attr('d', function (d) {
return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
});
linkLabels.attr('transform', function (d) {
if (d.target.x < d.source.x) {
var bbox = this.getBBox();
rx = bbox.x + bbox.width / 2;
ry = bbox.y + bbox.height / 2;
return 'rotate(180 ' + rx + ' ' + ry + ')';
}
else {
return 'rotate(0)';
}
});
}
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
}
</script>
</body>
</html>
For those who face a similar problem. I played around a bit and got it done by adding an empty .text("") attribute to the ".linklabel" class. The correct version looks liks:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>D3 JOIN Test</title>
<!-- call external d3.js framework -->
<script src="https://d3js.org/d3.v6.js"></script>
</head>
<style>
body {
overflow: hidden;
margin: 0px;
}
.canvas {
background-color: rgb(220, 220, 220);
}
.link {
cursor: pointer;
stroke: rgb(0, 0, 0);
stroke-width: 4px;
}
.link:hover {
stroke: red;
}
</style>
<body>
<svg id="svg"> </svg>
<script>
var graph = {
"nodes": [
{
"id": 1,
},
{
"id": 2,
},
{
"id": 3,
}
],
"links": [
{
"source": 1,
"target": 2,
"type": "blue"
},
{
"source": 2,
"target": 3,
"type": "blue"
},
{
"source": 3,
"target": 1,
"type": "blue"
}
]
}
// declare initial variables
var svg = d3.select("svg")
width = window.innerWidth
height = window.innerHeight
// define cavnas area to draw everything
svg = d3.select("svg")
.attr("class", "canvas")
.attr("width", width)
.attr("height", height)
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.append("g")
// remove zoom on dblclick listener
d3.select("svg").on("dblclick.zoom", null)
var linksContainer = svg.append("g").attr("class", "linksContainer")
var nodesContainer = svg.append("g").attr("class", "nodesContainer")
// iniital force simulation
var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) {
return d.id;
}).distance(200))
.force("charge", d3.forceManyBody().strength(-100))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("attraceForce", d3.forceManyBody().strength(70));
initialze()
function initialze() {
links = linksContainer.selectAll(".link")
.data(graph.links)
.join("line")
.attr("class", "link")
.on("click", click)
linkPaths = linksContainer.selectAll(".linkPath")
.data(graph.links)
.join("path")
.attr("class", "linkPath")
.attr("id", function (d, i) { return "linkPath" + i })
linkLabels = linksContainer.selectAll(".linkLabel")
.data(graph.links)
.join("text")
.attr("class", "linkLabel")
.attr("id", function (d, i) { return "linkLabel" + i })
.attr("font-size", 16)
.attr("fill", "black")
.text("")
linkLabels
.append("textPath")
.attr('xlink:href', function (d, i) { return '#linkPath' + i })
.style("text-anchor", "middle")
.attr("startOffset", "50%")
.text(function (d) { return d.type })
nodes = nodesContainer.selectAll(".node")
.data(graph.nodes, d => d.id)
.join("circle")
.attr("class", "node")
.attr("r", 30)
.attr("fill", "whitesmoke")
.attr("stroke", "white")
.call(d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
simulation
.nodes(graph.nodes)
.on("tick", ticked);
simulation
.force("link")
.links(graph.links)
}
function click(event, d) {
if (d.type == "blue") {
d.type = "green"
} else if (d.type == "green") {
d.type = "blue"
}
initialze()
}
function ticked() {
links
.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
nodes
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
linkPaths.attr('d', function (d) {
return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y;
});
linkLabels.attr('transform', function (d) {
if (d.target.x < d.source.x) {
var bbox = this.getBBox();
rx = bbox.x + bbox.width / 2;
ry = bbox.y + bbox.height / 2;
return 'rotate(180 ' + rx + ' ' + ry + ')';
}
else {
return 'rotate(0)';
}
});
}
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
}
</script>
</body>
</html>
Currently, I am building a system, and I am having some trouble with the update function.
Essentially, I am trying to add new nodes to a D3 tree. A new child node can be added when the user clicks the "add button" of a node. Each add button can be found on the left side of each node.
I have followed Mike Bostock's general update pattern. Once I click on the button, the only "new" data element should be the newly created child node, but it looks like the entire data is being treat as "new". I came to this conclusion when I looked at the class names for each node and the obvious fact that there is a transition of all the nodes coming to the central node and disappearing. The other original data should be "updated", but it's not. Can anyone please gently point out as to why this is happening?
A working sample of my code can be found in this jfiddle link.
EDIT 06/09
Given Gordon's suggestion, I have found a unique field for both of my nodes and link. So to uniquely identify the data I have made the following change:
NODE
.data(d, d => d.data.name)
LINK
.data(d, d => d.source.data.name)
This change works (mostly), but I see that some weird behaviors are still occurring: (1) Branch 7.2.1 is still being recognized a new node and disappear; (2) links are not being properly aligned with their respective node after the second "add" or so. I think my two small edits are affecting this because when I went back to the original code, the lines are being properly drawn, despite the fact they're transitioning away. Thoughts? Advice?
HTML
<div id="div-mindMap">
CSS
.linkMindMap {
fill: none;
stroke: #555;
stroke-opacity: 0.4;
}
rect {
fill: white;
stroke: #3182bd;
stroke-width: 1.5px;
}
JS
const widthMindMap = 700;
const heightMindMap = 700;
let parsedData;
let parsedList = {
"name": " Stapler",
"children": [{
"name": " Bind",
"children": []
},
{
"name": " Nail",
"children": []
},
{
"name": " String",
"children": []
},
{
"name": " Glue",
"children": [{
"name": "Gum",
"children": []
},
{
"name": "Sticky Gum",
"children": []
}
]
},
{
"name": " Branch 3",
"children": []
},
{
"name": " Branch 4",
"children": [{
"name": " Branch 4.1",
"children": []
},
{
"name": " Branch 4.2",
"children": []
},
{
"name": " Branch 4.1",
"children": []
}
]
},
{
"name": " Branch 5",
"children": []
},
{
"name": " Branch 6",
"children": []
},
{
"name": " Branch 7",
"children": []
},
{
"name": " Branch 7.1",
"children": []
},
{
"name": " Branch 7.2",
"children": [{
"name": " Branch 7.2.1",
"children": []
},
{
"name": " Branch 7.2.1",
"children": []
}
]
}
]
}
let svgMindMap = d3.select('#div-mindMap')
.append("svg")
.attr("id", "svg-mindMap")
.attr("width", widthMindMap)
.attr("height", heightMindMap);
let backgroundLayer = svgMindMap.append('g')
.attr("width", widthMindMap)
.attr("height", heightMindMap)
.attr("class", "background")
let gLeft = backgroundLayer.append("g")
.attr("transform", "translate(" + widthMindMap / 2 + ",0)")
.attr("class", "g-left");
let gLeftLink = gLeft.append('g')
.attr('class', 'g-left-link');
let gLeftNode = gLeft.append('g')
.attr('class', 'g-left-node');
function loadMindMap(parsed) {
var data = parsed;
var split_index = Math.round(data.children.length / 2);
parsedData = {
"name": data.name,
"children": JSON.parse(JSON.stringify(data.children.slice(split_index)))
};
var left = d3.hierarchy(parsedData, d => d.children);
drawLeft(left, "left");
}
// draw single tree
function drawLeft(root, pos) {
var SWITCH_CONST = 1;
if (pos === "left") SWITCH_CONST = -1;
update(root, SWITCH_CONST);
}
function update(source, SWITCH_CONST) {
var tree = d3.tree()
.size([heightMindMap, SWITCH_CONST * (widthMindMap - 150) / 2]);
var root = tree(source);
console.log(root)
var nodes = root.descendants();
var links = root.links();
console.log(nodes)
console.log(links)
// Set both root nodes to be dead center vertically
nodes[0].x = heightMindMap / 2
//JOIN new data with old elements
var link = gLeftLink.selectAll(".link-left")
.data(links, d => d)
.style('stroke-width', 1.5);
var linkEnter = link.enter().append("path")
.attr("class", "linkMindMap link-left")
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x));
var linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(750)
var linkExit = link.exit()
.transition()
.duration(750)
.attr('x1', function(d) {
return root.x;
})
.attr('y1', function(d) {
return root.y;
})
.attr('x2', function(d) {
return root.x;
})
.attr('y2', function(d) {
return root.y;
})
.remove();
//JOIN new data with old elements
var node = gLeftNode.selectAll(".nodeMindMap-left")
.data(nodes, d => d);
console.log(nodes);
//ENTER new elements present in new data
var nodeEnter = node.enter().append("g").merge(node)
.attr("class", function(d) {
return "nodeMindMap-left " + "nodeMindMap" + (d.children ? " node--internal" : " node--leaf");
})
.classed("enter", true)
.attr("transform", function(d) {
return "translate(" + d.y + "," + d.x + ")";
})
.attr("id", function(d) {
let str = d.data.name;
str = str.replace(/\s/g, '');
return str;
});
nodeEnter.append("circle")
.attr("r", function(d, i) {
return 2.5
});
var addLeftChild = nodeEnter.append("g")
.attr("class", "addHandler")
.attr("id", d => {
let str = d.data.name;
str = "addHandler-" + str.replace(/\s/g, '');
return str;
})
.style("opacity", "1")
.on("click", (d, i, nodes) => addNewLeftChild(d, i, nodes));
addLeftChild.append("line")
.attr("x1", -74)
.attr("y1", 1)
.attr("x2", -50)
.attr("y2", 1)
.attr("stroke", "#85e0e0")
.style("stroke-width", "2");
addLeftChild.append("rect")
.attr("x", "-77")
.attr("y", "-7")
.attr("height", 15)
.attr("width", 15)
.attr("rx", 5)
.attr("ry", 5)
.style("stroke", "#444")
.style("stroke-width", "1")
.style("fill", "#ccc");
addLeftChild.append("line")
.attr("x1", -74)
.attr("y1", 1)
.attr("x2", -65)
.attr("y2", 1)
.attr("stroke", "#444")
.style("stroke-width", "1.5");
addLeftChild.append("line")
.attr("x1", -69.5)
.attr("y1", -3)
.attr("x2", -69.5)
.attr("y2", 5)
.attr("stroke", "#444")
.style("stroke-width", "1.5");
// .call(d3.drag().on("drag", dragged));;
nodeEnter.append("foreignObject")
.style("fill", "blue")
.attr("x", -50)
.attr("y", -7)
.attr("height", "20px")
.attr("width", "100px")
.append('xhtml:div')
.append('div')
.attr("class", 'clickable-node')
.attr("id", function(d) {
let str = d.data.name;
str = "div-" + str.replace(/\s/g, '');
return str;
})
.attr("ondblclick", "this.contentEditable=true")
.attr("onblur", "this.contentEditable=false")
.attr("contentEditable", "false")
.style("text-align", "center")
.text(d => d.data.name);
//TODO: make it dynamic
nodeEnter.insert("rect", "foreignObject")
.attr("ry", 6)
.attr("rx", 6)
.attr("y", -10)
.attr("height", 20)
.attr("width", 100)
// .filter(function(d) { return d.flipped; })
.attr("x", -50)
.classed("selected", false)
.attr("id", function(d) {
let str = d.data.name;
str = "rect-" + str.replace(/\s/g, '');
return str;
});
var nodeUpdate = nodeEnter.merge(node);
// Transition to the proper position for the node
nodeUpdate.transition()
.duration(750)
.attr("transform", function(d) {
return "translate(" + d.y + "," + d.x + ")";
});
// Remove any exiting nodes
var nodeExit = node.exit()
.transition()
.duration(750)
.attr("transform", function(d) {
return "translate(" + source.y + "," + source.x + ")";
})
.remove();
// On exit reduce the node circles size to 0
nodeExit.select('circle').attr('r', 0);
// node = nodeEnter.merge(node)
}
function addNewLeftChild(d, i, nodes) {
console.log("make new child");
event.stopPropagation();
var newNodeObj = {
// name: new Date().getTime(),
name: "New Child",
children: []
};
console.log("this is ", parsedData)
//Creates new Node
var newNode = d3.hierarchy(newNodeObj);
newNode.depth = d.depth + 1;
newNode.height = d.height - 1;
newNode.parent = d;
newNode.id = Date.now();
console.log(newNode);
console.log(d)
if (d.data.children.length == 0) {
console.log("i have no children")
d.children = []
}
d.children.push(newNode)
d.data.children.push(newNode.data)
console.log(d)
let foo = d3.hierarchy(parsedData, d => d.children)
drawLeft(foo, "left");
}
loadMindMap(parsedList);
There are a few things going on:
Using Unique Keys
Using names isn't the best for keys, because each new node has the same name ("New Child"). Instead it's probably better to use some sort of ID system. Here's a quick function to tag the data for each node with an ID.
let currNodeId = 0;
function idData(node) {
node.nodeId = ++currNodeId;
node.children.forEach(idData);
}
idData(parsedList);
And since you're redefining the data in parsedData, you need to use the id property there too:
parsedData = {
"name": data.name,
"nodeId": data.nodeId,
"children": JSON.parse(JSON.stringify(data.children.slice(split_index)))
};
When adding a new node, you can also set it in the nodeData:
var newNodeObj = {
// name: new Date().getTime(),
name: "New Child",
nodeId: ++currNodeId,
children: []
};
Then to actually use .nodeId as the key for nodes, use it as the key function:
.data(nodes, d => d.data.nodeId);
For links, you should use the target instead of the source, since this is a tree and there is only one link per child (instead of multiple links for one parent).
.data(nodes, d => d.target.data.nodeId);
Prevent multiple node elements from being added
There's also an issue where you're merging your new and old nodes before adding new elements. To prevent this, you should change
node.enter().append("g").merge(node)
to:
node.enter().append("g")
Link transitions
Finally the transitions for your links are not transitioning with the nodes. To make them transition, move:
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x));
to under
linkUpdate.transition()
.duration(750)
All together it looks like this: https://jsfiddle.net/v9wyb6q4/
Or:
const widthMindMap = 700;
const heightMindMap = 700;
let parsedData;
let parsedList = {
"name": " Stapler",
"children": [{
"name": " Bind",
"children": []
},
{
"name": " Nail",
"children": []
},
{
"name": " String",
"children": []
},
{
"name": " Glue",
"children": [{
"name": "Gum",
"children": []
},
{
"name": "Sticky Gum",
"children": []
}
]
},
{
"name": " Branch 3",
"children": []
},
{
"name": " Branch 4",
"children": [{
"name": " Branch 4.1",
"children": []
},
{
"name": " Branch 4.2",
"children": []
},
{
"name": " Branch 4.1",
"children": []
}
]
},
{
"name": " Branch 5",
"children": []
},
{
"name": " Branch 6",
"children": []
},
{
"name": " Branch 7",
"children": []
},
{
"name": " Branch 7.1",
"children": []
},
{
"name": " Branch 7.2",
"children": [{
"name": " Branch 7.2.1",
"children": []
},
{
"name": " Branch 7.2.1",
"children": []
}
]
}
]
}
let currNodeId = 0;
function idData(node) {
node.nodeId = ++currNodeId;
node.children.forEach(idData);
}
idData(parsedList);
let svgMindMap = d3.select('#div-mindMap')
.append("svg")
.attr("id", "svg-mindMap")
.attr("width", widthMindMap)
.attr("height", heightMindMap);
let backgroundLayer = svgMindMap.append('g')
.attr("width", widthMindMap)
.attr("height", heightMindMap)
.attr("class", "background")
let gLeft = backgroundLayer.append("g")
.attr("transform", "translate(" + widthMindMap / 2 + ",0)")
.attr("class", "g-left");
let gLeftLink = gLeft.append('g')
.attr('class', 'g-left-link');
let gLeftNode = gLeft.append('g')
.attr('class', 'g-left-node');
function loadMindMap(parsed) {
var data = parsed;
var split_index = Math.round(data.children.length / 2);
parsedData = {
"name": data.name,
"nodeId": data.nodeId,
"children": JSON.parse(JSON.stringify(data.children.slice(split_index)))
};
var left = d3.hierarchy(parsedData, d => d.children);
drawLeft(left, "left");
}
// draw single tree
function drawLeft(root, pos) {
var SWITCH_CONST = 1;
if (pos === "left") SWITCH_CONST = -1;
update(root, SWITCH_CONST);
}
function update(source, SWITCH_CONST) {
var tree = d3.tree()
.size([heightMindMap, SWITCH_CONST * (widthMindMap - 150) / 2]);
var root = tree(source);
console.log(root)
var nodes = root.descendants();
var links = root.links();
console.log(nodes)
console.log(links)
// Set both root nodes to be dead center vertically
nodes[0].x = heightMindMap / 2
//JOIN new data with old elements
var link = gLeftLink.selectAll(".link-left")
.data(links, d => d.target.data.nodeId)
.style('stroke-width', 1.5);
var linkEnter = link.enter().append("path")
.attr("class", "linkMindMap link-left");
var linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(750)
.attr("d", d3.linkHorizontal()
.x(d => d.y)
.y(d => d.x));
var linkExit = link.exit()
.transition()
.duration(750)
.attr('x1', function(d) {
return root.x;
})
.attr('y1', function(d) {
return root.y;
})
.attr('x2', function(d) {
return root.x;
})
.attr('y2', function(d) {
return root.y;
})
.remove();
//JOIN new data with old elements
var node = gLeftNode.selectAll(".nodeMindMap-left")
.data(nodes, d => d.data.nodeId);
console.log(nodes);
//ENTER new elements present in new data
var nodeEnter = node.enter().append("g")
.attr("class", function(d) {
return "nodeMindMap-left " + "nodeMindMap" + (d.children ? " node--internal" : " node--leaf");
})
.classed("enter", true)
.attr("transform", function(d) {
return "translate(" + d.y + "," + d.x + ")";
})
.attr("id", function(d) {
let str = d.data.name;
str = str.replace(/\s/g, '');
return str;
});
nodeEnter.append("circle")
.attr("r", function(d, i) {
return 2.5
});
var addLeftChild = nodeEnter.append("g")
.attr("class", "addHandler")
.attr("id", d => {
let str = d.data.name;
str = "addHandler-" + str.replace(/\s/g, '');
return str;
})
.style("opacity", "1")
.on("click", (d, i, nodes) => addNewLeftChild(d, i, nodes));
addLeftChild.append("line")
.attr("x1", -74)
.attr("y1", 1)
.attr("x2", -50)
.attr("y2", 1)
.attr("stroke", "#85e0e0")
.style("stroke-width", "2");
addLeftChild.append("rect")
.attr("x", "-77")
.attr("y", "-7")
.attr("height", 15)
.attr("width", 15)
.attr("rx", 5)
.attr("ry", 5)
.style("stroke", "#444")
.style("stroke-width", "1")
.style("fill", "#ccc");
addLeftChild.append("line")
.attr("x1", -74)
.attr("y1", 1)
.attr("x2", -65)
.attr("y2", 1)
.attr("stroke", "#444")
.style("stroke-width", "1.5");
addLeftChild.append("line")
.attr("x1", -69.5)
.attr("y1", -3)
.attr("x2", -69.5)
.attr("y2", 5)
.attr("stroke", "#444")
.style("stroke-width", "1.5");
// .call(d3.drag().on("drag", dragged));;
nodeEnter.append("foreignObject")
.style("fill", "blue")
.attr("x", -50)
.attr("y", -7)
.attr("height", "20px")
.attr("width", "100px")
.append('xhtml:div')
.append('div')
.attr("class", 'clickable-node')
.attr("id", function(d) {
let str = d.data.name;
str = "div-" + str.replace(/\s/g, '');
return str;
})
.attr("ondblclick", "this.contentEditable=true")
.attr("onblur", "this.contentEditable=false")
.attr("contentEditable", "false")
.style("text-align", "center")
.text(d => d.data.name);
//TODO: make it dynamic
nodeEnter.insert("rect", "foreignObject")
.attr("ry", 6)
.attr("rx", 6)
.attr("y", -10)
.attr("height", 20)
.attr("width", 100)
// .filter(function(d) { return d.flipped; })
.attr("x", -50)
.classed("selected", false)
.attr("id", function(d) {
let str = d.data.name;
str = "rect-" + str.replace(/\s/g, '');
return str;
});
var nodeUpdate = nodeEnter.merge(node);
// Transition to the proper position for the node
nodeUpdate.transition()
.duration(750)
.attr("transform", function(d) {
return "translate(" + d.y + "," + d.x + ")";
});
// Remove any exiting nodes
var nodeExit = node.exit()
.transition()
.duration(750)
.attr("transform", function(d) {
return "translate(" + source.y + "," + source.x + ")";
})
.remove();
// On exit reduce the node circles size to 0
nodeExit.select('circle').attr('r', 0);
// node = nodeEnter.merge(node)
}
function addNewLeftChild(d, i, nodes) {
console.log("make new child");
event.stopPropagation();
var newNodeObj = {
// name: new Date().getTime(),
name: "New Child",
nodeId: ++currNodeId,
children: []
};
console.log("this is ", parsedData)
//Creates new Node
var newNode = d3.hierarchy(newNodeObj);
newNode.depth = d.depth + 1;
newNode.height = d.height - 1;
newNode.parent = d;
newNode.id = Date.now();
console.log(newNode);
console.log(d)
if (d.data.children.length == 0) {
console.log("i have no children")
d.children = []
}
d.children.push(newNode)
d.data.children.push(newNode.data)
console.log(d)
let foo = d3.hierarchy(parsedData, d => d.children)
drawLeft(foo, "left");
}
loadMindMap(parsedList);
.linkMindMap {
fill: none;
stroke: #555;
stroke-opacity: 0.4;
}
rect {
fill: white;
stroke: #3182bd;
stroke-width: 1.5px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<div id="div-mindMap">
These sorts of problems usually arise because the key function which you pass as the second parameter to selection.data() is not unique or "too unique".
It should return a simple value which uniquely identifies each datum. In your case
.data(nodes, d => d);
might be better as
.data(nodes, d => d.name);
and similarly for the links.
However you will need to examine the output of d3.tree to see what fields the nodes and links have and which field contains a unique id.
This is a 2nd question that builds off of this a previous question of mine here - D3 Force Graph With Arrows and Curved Edges - shorten links so arrow doesnt overlap nodes - on how to shorten curved links for a d3 force graph.
My latest struggle involves centering the text placed on top of the links, actually over the links. Here is a reproducible example showing my issue (apologies for the long code. a lot was needed to create a reproducible example, although I am only working on a small bit of it currently):
const svg = d3.select('#mySVG')
const nodesG = svg.select("g.nodes")
const linksG = svg.select("g.links")
var graphs = {
"nodes": [{
"name": "Peter",
"label": "Person",
"id": 1
},
{
"name": "Michael",
"label": "Person",
"id": 2
},
{
"name": "Neo4j",
"label": "Database",
"id": 3
},
{
"name": "Graph Database",
"label": "Database",
"id": 4
}
],
"links": [{
"source": 1,
"target": 2,
"type": "KNOWS",
"since": 2010
},
{
"source": 1,
"target": 3,
"type": "FOUNDED"
},
{
"source": 2,
"target": 3,
"type": "WORKS_ON"
},
{
"source": 3,
"target": 4,
"type": "IS_A"
}
]
}
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 0)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 13)
.attr('markerHeight', 13)
.attr('xoverflow', 'visible')
.append('svg:path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#999')
.style('stroke', 'none');
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(100, 100));
let linksData = graphs.links.map(link => {
var obj = link;
obj.source = link.source;
obj.target = link.target;
return obj;
})
const links = linksG
.selectAll("g")
.data(graphs.links)
.enter().append("g")
.attr("cursor", "pointer")
const linkLines = links
.append("path")
.attr('stroke', '#000000')
.attr('opacity', 0.75)
.attr("stroke-width", 1)
.attr("fill", "transparent")
.attr('marker-end', 'url(#arrowhead)');
const linkText = links
.append("text")
.attr("x", d => (d.source.x + (d.target.x - d.source.x) * 0.5))
.attr("y", d => (d.source.y + (d.target.y - d.source.y) * 0.5))
.attr('stroke', '#000000')
.attr("text-anchor", "middle")
.attr('opacity', 1)
.text((d,i) => `${i}`);
const nodes = nodesG
.selectAll("g")
.data(graphs.nodes)
.enter().append("g")
.attr("cursor", "pointer")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
const circles = nodes.append("circle")
.attr("r", 12)
.attr("fill", "000000")
nodes.append("title")
.text(function(d) {
return d.id;
});
simulation
.nodes(graphs.nodes)
.on("tick", ticked);
simulation.force("link", d3.forceLink().links(linksData)
.id((d, i) => d.id)
.distance(150));
function ticked() {
linkLines.attr("d", function(d) {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
});
// recalculate and back off the distance
linkLines.attr("d", function(d) {
// length of current path
var pl = this.getTotalLength(),
// radius of circle plus backoff
r = (12) + 30,
// position close to where path intercepts circle
m = this.getPointAtLength(pl - r);
var dx = m.x - d.source.x,
dy = m.y - d.source.y,
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + m.x + "," + m.y;
});
linkText
.attr("x", function(d) { return (d.source.x + (d.target.x - d.source.x) * 0.5); })
.attr("y", function(d) { return (d.source.y + (d.target.y - d.source.y) * 0.5); })
nodes
.attr("transform", d => `translate(${d.x}, ${d.y})`);
}
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;
}
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="//d3js.org/d3.v4.min.js" type="text/javascript"></script>
</head>
<body>
<svg id="mySVG" width="500" height="500">
<g class="links" />
<g class="nodes" />
</svg>
I know that the issue with my code is with the setting of the x and y values for the linkText here:
linkText
.attr("x", function(d) { return (d.source.x + (d.target.x - d.source.x) * 0.5); })
.attr("y", function(d) { return (d.source.y + (d.target.y - d.source.y) * 0.5); })
...and also earlier in my code. I am not sure how to update these functions to account for the fact that the links are curved lines (not straight lines from node to node).
The larger force graph for my project has many more links and nodes, and positioning of the text over the center of the curved linkLines would be preferable.
Any help with this is appreciated!
There are several different ways to fix this. The two most obvious ones are:
Using getPointAtLength() to get the middle of the <path>, and positioning the texts there;
Using a <textPath> element.
In my solution I'll choose #2 mainly because, using a text path, the numbers can flip according the paths' orientation, some of them ending up upside down (I'm assuming that this is what you want).
So, we append the textPaths...
const linkText = links
.append("text")
.attr("dy", -4)
.append("textPath")
.attr("xlink:href", function(_, i) {
return "#path" + i
})
.attr("startOffset", "50%")
.text((d, i) => `${i}`);
... having given the paths unique ids:
.attr("id", function(_, i) {
return "path" + i
})
This is the code with those changes:
const svg = d3.select('#mySVG')
const nodesG = svg.select("g.nodes")
const linksG = svg.select("g.links")
var graphs = {
"nodes": [{
"name": "Peter",
"label": "Person",
"id": 1
},
{
"name": "Michael",
"label": "Person",
"id": 2
},
{
"name": "Neo4j",
"label": "Database",
"id": 3
},
{
"name": "Graph Database",
"label": "Database",
"id": 4
}
],
"links": [{
"source": 1,
"target": 2,
"type": "KNOWS",
"since": 2010
},
{
"source": 1,
"target": 3,
"type": "FOUNDED"
},
{
"source": 2,
"target": 3,
"type": "WORKS_ON"
},
{
"source": 3,
"target": 4,
"type": "IS_A"
}
]
}
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 0)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 13)
.attr('markerHeight', 13)
.attr('xoverflow', 'visible')
.append('svg:path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#999')
.style('stroke', 'none');
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(100, 100));
let linksData = graphs.links.map(link => {
var obj = link;
obj.source = link.source;
obj.target = link.target;
return obj;
})
const links = linksG
.selectAll("g")
.data(graphs.links)
.enter().append("g")
.attr("cursor", "pointer")
const linkLines = links
.append("path")
.attr("id", function(_, i) {
return "path" + i
})
.attr('stroke', '#000000')
.attr('opacity', 0.75)
.attr("stroke-width", 1)
.attr("fill", "transparent")
.attr('marker-end', 'url(#arrowhead)');
const linkText = links
.append("text")
.attr("dy", -4)
.append("textPath")
.attr("xlink:href", function(_, i) {
return "#path" + i
})
.attr("startOffset", "50%")
.attr('stroke', '#000000')
.attr('opacity', 1)
.text((d, i) => `${i}`);
const nodes = nodesG
.selectAll("g")
.data(graphs.nodes)
.enter().append("g")
.attr("cursor", "pointer")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
const circles = nodes.append("circle")
.attr("r", 12)
.attr("fill", "000000")
nodes.append("title")
.text(function(d) {
return d.id;
});
simulation
.nodes(graphs.nodes)
.on("tick", ticked);
simulation.force("link", d3.forceLink().links(linksData)
.id((d, i) => d.id)
.distance(150));
function ticked() {
linkLines.attr("d", function(d) {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
});
// recalculate and back off the distance
linkLines.attr("d", function(d) {
// length of current path
var pl = this.getTotalLength(),
// radius of circle plus backoff
r = (12) + 30,
// position close to where path intercepts circle
m = this.getPointAtLength(pl - r);
var dx = m.x - d.source.x,
dy = m.y - d.source.y,
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + m.x + "," + m.y;
});
linkText
.attr("x", function(d) {
return (d.source.x + (d.target.x - d.source.x) * 0.5);
})
.attr("y", function(d) {
return (d.source.y + (d.target.y - d.source.y) * 0.5);
})
nodes
.attr("transform", d => `translate(${d.x}, ${d.y})`);
}
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;
}
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="//d3js.org/d3.v4.min.js" type="text/javascript"></script>
</head>
<body>
<svg id="mySVG" width="500" height="500">
<g class="links" />
<g class="nodes" />
</svg>
On the other hand, if you don't want some of the texts upside down, use getPointAtLength() to get the middle of the path, which is the approach #1:
.attr("x", function(d) {
const length = this.previousSibling.getTotalLength();
return this.previousSibling.getPointAtLength(length/2).x
})
.attr("y", function(d) {
const length = this.previousSibling.getTotalLength();
return this.previousSibling.getPointAtLength(length/2).y
})
Here is the demo:
const svg = d3.select('#mySVG')
const nodesG = svg.select("g.nodes")
const linksG = svg.select("g.links")
var graphs = {
"nodes": [{
"name": "Peter",
"label": "Person",
"id": 1
},
{
"name": "Michael",
"label": "Person",
"id": 2
},
{
"name": "Neo4j",
"label": "Database",
"id": 3
},
{
"name": "Graph Database",
"label": "Database",
"id": 4
}
],
"links": [{
"source": 1,
"target": 2,
"type": "KNOWS",
"since": 2010
},
{
"source": 1,
"target": 3,
"type": "FOUNDED"
},
{
"source": 2,
"target": 3,
"type": "WORKS_ON"
},
{
"source": 3,
"target": 4,
"type": "IS_A"
}
]
}
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 0)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 13)
.attr('markerHeight', 13)
.attr('xoverflow', 'visible')
.append('svg:path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#999')
.style('stroke', 'none');
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(100, 100));
let linksData = graphs.links.map(link => {
var obj = link;
obj.source = link.source;
obj.target = link.target;
return obj;
})
const links = linksG
.selectAll("g")
.data(graphs.links)
.enter().append("g")
.attr("cursor", "pointer")
const linkLines = links
.append("path")
.attr('stroke', '#000000')
.attr('opacity', 0.75)
.attr("stroke-width", 1)
.attr("fill", "transparent")
.attr('marker-end', 'url(#arrowhead)');
const linkText = links
.append("text")
.attr("x", function(d) {
const length = this.previousSibling.getTotalLength();
return this.previousSibling.getPointAtLength(length / 2).x
})
.attr("y", function(d) {
const length = this.previousSibling.getTotalLength();
return this.previousSibling.getPointAtLength(length / 2).y
})
.attr('stroke', '#000000')
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr('opacity', 1)
.text((d, i) => `${i}`);
const nodes = nodesG
.selectAll("g")
.data(graphs.nodes)
.enter().append("g")
.attr("cursor", "pointer")
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
const circles = nodes.append("circle")
.attr("r", 12)
.attr("fill", "000000")
nodes.append("title")
.text(function(d) {
return d.id;
});
simulation
.nodes(graphs.nodes)
.on("tick", ticked);
simulation.force("link", d3.forceLink().links(linksData)
.id((d, i) => d.id)
.distance(150));
function ticked() {
linkLines.attr("d", function(d) {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
});
// recalculate and back off the distance
linkLines.attr("d", function(d) {
// length of current path
var pl = this.getTotalLength(),
// radius of circle plus backoff
r = (12) + 30,
// position close to where path intercepts circle
m = this.getPointAtLength(pl - r);
var dx = m.x - d.source.x,
dy = m.y - d.source.y,
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + m.x + "," + m.y;
});
linkText
.attr("x", function(d) {
const length = this.previousSibling.getTotalLength();
return this.previousSibling.getPointAtLength(length / 2).x
})
.attr("y", function(d) {
const length = this.previousSibling.getTotalLength();
return this.previousSibling.getPointAtLength(length / 2).y
})
nodes
.attr("transform", d => `translate(${d.x}, ${d.y})`);
}
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;
}
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="//d3js.org/d3.v4.min.js" type="text/javascript"></script>
</head>
<body>
<svg id="mySVG" width="500" height="500">
<g class="links" />
<g class="nodes" />
</svg>
Here I'm assuming that the <path> is the previous sibling of the <text>. If that's not the case in the real code, change this accordingly.