I got two issues with my forced graph. The first problem refers to the position of the link text. I added an offSet of 50%, to make sure that each linkText will be centered. This works pretty well if the linkText isn´t long. But it completely looks
akward as soon as the description is longer.
I am not sure if it would be possible to calculate the length of the needed linkText space and somehow subtract it from the given offSet. In general the amount of needed space is there, no clue if it could be used for further calculations.
My second problem relates to the link curves. I added those to be able to visualize bi-directional links. Otherwise those would be on top of each other. The thing is, as soon as you play with a target node and drag them around in a way, that the target node X-posiiton is smaller than the x-position of the source node, the linkText curved wrongly.
Maybe you guys got an idea or hint.
console.log("D3 Forced Layout ready.")
////////////////////////////////////////////////////////////
//////////////////// D3 Forced Graph ///////////////////////
////////////////////////////////////////////////////////////
var data = {
"nodes": [
{ "id": 1 },
{ "id": 2 },
{ "id": 3 },
{ "id": 4 },
{ "id": 5 }
],
"links": [
{ "source": 1, "target": 2, "text": "this description is not centered"},
{ "source": 2, "target": 1, "text": "Shorter description" },
{ "source": 2, "target": 3, "text": "Shorter description" },
{ "source": 3, "target": 4, "text": "even shorter" },
{ "source": 4, "target": 5, "text": "shorter" },
{ "source": 5, "target": 1, "text": "short" }
]
}
initForceLayout()
function initForceLayout() {
let vw = 800
let vh = 800
const svg = d3.select("#chart").append("svg")
.attr("width", vw)
.attr("height", vh)
const forceLayout = svg.append("g")
.attr("id", "forceLayout")
.call(d3.zoom().on("zoom", function (event) {
svg.attr("transform", event.transform)
}))
.on("dblclick.zoom", null)
linksContainer = forceLayout.append("g").attr("class", "linkscontainer")
nodesContainer = forceLayout.append("g").attr("class", "nodesContainer")
var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function (d) { return d.id; }).distance(300))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(vw / 2, vh / 2))
link = linksContainer.selectAll("g")
.data(data.links)
.join("g")
.attr("cursor", "pointer")
linkLine = linksContainer.selectAll(".linkPath")
.data(data.links)
.join("path")
.attr("id", function(_,i) {
return "path" + i
})
.attr("stroke", "black")
.attr("opacity", 0.75)
.attr("stroke-width", 3)
.attr("fill", "transparent")
linkText = linksContainer.selectAll(".linkLabel")
.data(data.links)
.join("text")
.attr("dy", -10)
.attr("class", "linkLabel")
.attr("id", function (d, i) {return "linkLabel" + i })
.text("")
linkText.append("textPath")
.attr("xlink:href", function (_, i) {
return "#path" + i
})
.attr("startOffset", "50%")
.attr("opacity", 0.75)
.attr("cursor", "pointer")
.attr("class", "linkText")
.text(function (d) {
return d.text
})
node = nodesContainer.selectAll(".node")
.data(data.nodes, d => d.id)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", function(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", function(event, d) {
d.fx = event.x;
d.fy = event.y;
})
.on("end", function(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
})
)
node.selectAll("circle")
.data(d => [d])
.join("circle")
.attr("r", 30)
.attr("fill", "whitesmoke")
.attr("stroke", "white")
.attr("stroke-width", 2)
simulation
.nodes(data.nodes)
.on("tick", function () {
// update link positions
linkLine.attr("d", function (d) {
if (d.target.x > d.source.x) {
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;
} else if (d.target.x < d.source.x) {
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;
}
});
// update node positions
node
.attr("transform", function (d) {
return "translate(" + d.x + ", " + d.y + ")";
});
linkText.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 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(0 ' + rx + ' ' + ry + ')';
}
})
})
simulation
.force("link")
.links(data.links)
}
:root {
--bs-gradient-dark-right: #141727;
--bs-gradient-dark-left: #3a416f;
}
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
body {
background-color: lightgray;
overflow: hidden;
}
.border-radius-lg {
border-radius: 0.75rem;
}
.svg-container {
display: inline-block;
position: relative;
width: 100%;
padding-bottom: 100%; /* aspect ratio */
vertical-align: top;
overflow: hidden;
}
#svg-content-responsive {
display: inline-block;
position: absolute;
top: 10px;
left: 0;
}
svg .rect {
fill: gold;
stroke: steelblue;
stroke-width: 5px;
}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<title>linkText</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.1/d3.min.js" charset="utf-8"></script>
</head>
<body>
<div id="chart"></div>
</body>
</html>
text-anchor:middle
property to <text />
is enough to centering the text.textPath
element as data attribute. with this, manually aligning the text is also possible.textLink./* ... */.each(function(d,i) {
// ANSWER EDIT: precalculate text width
var thisWidth = this.getComputedTextLength()
d3.select(this).attr('data-text-width', () => thisWidth)
})
<textPath />
text, it is better to draw the path that starts from the opposite direction, rather than using transform:rotate(180deg)
.<textPath />
.EDIT: to avoid two lines collide each other, the sweep flag for SVG path arc has been modified. the flag indicates arc's direction.
console.log("D3 Forced Layout ready.")
////////////////////////////////////////////////////////////
//////////////////// D3 Forced Graph ///////////////////////
////////////////////////////////////////////////////////////
var data = {
"nodes": [{
"id": 1
},
{
"id": 2
},
{
"id": 3
},
{
"id": 4
},
{
"id": 5
}
],
"links": [{
"source": 1,
"target": 2,
"text": "this description is not centered"
},
{
"source": 2,
"target": 1,
"text": "Shorter description"
},
{
"source": 2,
"target": 3,
"text": "Shorter description"
},
{
"source": 3,
"target": 4,
"text": "even shorter"
},
{
"source": 4,
"target": 5,
"text": "shorter"
},
{
"source": 5,
"target": 1,
"text": "short"
}
]
}
initForceLayout()
function initForceLayout() {
let vw = 800
let vh = 800
const svg = d3.select("#chart").append("svg")
.attr("width", vw)
.attr("height", vh)
const forceLayout = svg.append("g")
.attr("id", "forceLayout")
.call(d3.zoom().on("zoom", function(event) {
svg.attr("transform", event.transform)
}))
.on("dblclick.zoom", null)
linksContainer = forceLayout.append("g").attr("class", "linkscontainer")
nodesContainer = forceLayout.append("g").attr("class", "nodesContainer")
var simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function(d) {
return d.id;
}).distance(300))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(vw / 2, vh / 2))
link = linksContainer.selectAll("g")
.data(data.links)
.join("g")
.attr("cursor", "pointer")
linkLine = linksContainer.selectAll(".linkPath")
.data(data.links)
.join("path")
.attr("id", function(_, i) {
return "path" + i
})
.attr("stroke", "black")
.attr("opacity", 0.75)
.attr("stroke-width", 3)
.attr("fill", "transparent")
linkText = linksContainer.selectAll(".linkLabel")
.data(data.links)
.join("text")
.attr("dy", -10)
.attr("class", "linkLabel")
.attr("id", function(d, i) {
return "linkLabel" + i
})
// ANSWER EDIT: added text-anchor middle property
// so that the text is centered properly
.attr('text-anchor', 'middle')
.text("")
linkText.append("textPath")
.attr("xlink:href", function(_, i) {
return "#path" + i
})
.attr("opacity", 0.75)
.attr("cursor", "pointer")
.attr("class", "linkText")
.attr('startOffset', '50%')
.text(function(d) {
return d.text
}).each(function(d,i) {
// ANSWER EDIT: precalculate text width
var thisWidth = this.getComputedTextLength()
d3.select(this).attr('data-text-width', () => thisWidth)
})
node = nodesContainer.selectAll(".node")
.data(data.nodes, d => d.id)
.join("g")
.attr("class", "node")
.call(d3.drag()
.on("start", function(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", function(event, d) {
d.fx = event.x;
d.fy = event.y;
})
.on("end", function(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = undefined;
d.fy = undefined;
})
)
node.selectAll("circle")
.data(d => [d])
.join("circle")
.attr("r", 30)
.attr("fill", "whitesmoke")
.attr("stroke", "white")
.attr("stroke-width", 2)
simulation
.nodes(data.nodes)
.on("tick", function() {
// update link positions
linkLine.attr("d", function(d) {
const shouldInvert = d.target.x < d.source.x
if (!shouldInvert) {
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;
} else {
var dx = (d.target.x - d.source.x),
dy = (d.target.y - d.source.y),
dr = Math.sqrt(dx * dx + dy * dy)
// ANSWER EDIT: swapped source and target
// ANSWER EDIT2: changed sweep flag 1 to 0
return "M" + d.target.x + "," + d.target.y + "A" + dr + "," + dr + " 0 0,0 " + d.source.x + "," + d.source.y;
}
});
// update node positions
node
.attr("transform", function(d) {
return "translate(" + d.x + ", " + d.y + ")";
});
// ANSWER EDIT: removed 180 degree transform
// this was redundant
})
simulation
.force("link")
.links(data.links)
}
:root {
--bs-gradient-dark-right: #141727;
--bs-gradient-dark-left: #3a416f;
}
html,
body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
body {
background-color: lightgray;
overflow: hidden;
}
.border-radius-lg {
border-radius: 0.75rem;
}
.svg-container {
display: inline-block;
position: relative;
width: 100%;
padding-bottom: 100%;
/* aspect ratio */
vertical-align: top;
overflow: hidden;
}
#svg-content-responsive {
display: inline-block;
position: absolute;
top: 10px;
left: 0;
}
svg .rect {
fill: gold;
stroke: steelblue;
stroke-width: 5px;
}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<title>linkText</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.1/d3.min.js" charset="utf-8"></script>
</head>
<body>
<div id="chart"></div>
</body>
</html>