Skip to content

Instantly share code, notes, and snippets.

@mattkohl
Last active March 26, 2024 18:26
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattkohl/146d301c0fc20d89d85880df537de7b0 to your computer and use it in GitHub Desktop.
Save mattkohl/146d301c0fc20d89d85880df537de7b0 to your computer and use it in GitHub Desktop.
Force-Directed Graph with Multiple Labelled Edges between Nodes
license: gpl-3.0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Force Layout with multiple labelled edges between nodes</title>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans);
body {
background: #384d54;
color: white;
}
text {
fill: white;
font-family: 'Open Sans';
}
circle {
stroke: white;
stroke-width: 1.5px;
}
path.link, path.textpath {
fill: none;
stroke: #cccccc;
stroke-width: 0.5px;
}
path.invis {
fill: none;
stroke-width: 0;
}
</style>
</head>
<body>
<script src="https://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
<script type="text/javascript">
var data = {
edges: [
{
source: {id: 0, label: "Naughty"},
target: {id: 1, label: "By"},
value: "You 'bout to feel"
},
{
source: {id: 0, label: "Naughty"},
target: {id: 1, label: "By"},
value: "the chronicles of a bionical lyric"
},
{
source: {id: 0, label: "Naughty"},
target: {id: 1, label: "By"},
value: "Lyrically splitting dismissing"
},
{
source: {id: 1, label: "By"},
target: {id: 2, label: "Nature"},
value: "I'm on a mission of just hitting"
},
{
source: {id: 1, label: "By"},
target: {id: 2, label: "Nature"},
value: "Now it's written and"
},
{
source: {id: 1, label: "By"},
target: {id: 2, label: "Nature"},
value: "kitten hitting wit mittens"
},
{
source: {id: 2, label: "Nature"},
target: {id: 0, label: "Naughty"},
value: "I'm missing, wishing, man, listen"
},
{
source: {id: 2, label: "Nature"},
target: {id: 0, label: "Naughty"},
value: "I glisten like"
},
{
source: {id: 2, label: "Nature"},
target: {id: 0, label: "Naughty"},
value: "sun and water while fishing"
},
]
};
function myGraph() {
this.addNode = function (n) {
if (!findNode(n.id)) {
nodes.push({"id": n.id, "label": n.label});
update();
}
};
this.addLink = function (source, target, value) {
links.push({"source": findNode(source.id), "target": findNode(target.id), "value": value});
update();
};
this.initialize = function() {
data.edges.forEach(function(d) {
graph.addNode(d.source);
graph.addNode(d.target);
graph.addLink(d.source, d.target, d.value);
});
};
var findNode = function (nodeId) {
for (var i in nodes) {
if (nodes[i].id === nodeId) {
return nodes[i];
}
};
};
var countSiblingLinks = function(source, target) {
var count = 0;
for(var i = 0; i < links.length; ++i){
if( (links[i].source.id == source.id && links[i].target.id == target.id) || (links[i].source.id == target.id && links[i].target.id == source.id) )
count++;
};
return count;
};
var getSiblingLinks = function(source, target) {
var siblings = [];
for(var i = 0; i < links.length; ++i){
if( (links[i].source.id == source.id && links[i].target.id == target.id) || (links[i].source.id == target.id && links[i].target.id == source.id) )
siblings.push(links[i].value);
};
return siblings;
};
var w = window.innerWidth - 20,
h = window.innerHeight,
middle = w/2;
var linkDistance = 300;
var colors = d3.scale.category20();
var svg = d3.select("body")
.append("svg:svg")
.attr("width", w)
.attr("height", h)
.style("z-index", -10)
.attr("id", "svg");
svg.append('svg:defs').selectAll('marker')
.data(['end'])
.enter()
.append('svg:marker')
.attr({'id': "arrowhead",
'viewBox':'0 -5 10 10',
'refX': 22,
'refY': 0,
'orient':'auto',
'markerWidth': 20,
'markerHeight': 20,
'markerUnits': "strokeWidth",
'xoverflow':'visible'})
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#ccc');
var force = d3.layout.force();
var nodes = force.nodes(),
links = force.links();
var update = function () {
var path = svg.selectAll("path.link")
.data(force.links());
path.enter().append("svg:path")
.attr("id", function (d) {
return d.source.id + "-" + d.value + "-" + d.target.id;
})
.attr("class", "link")
.attr('marker-end','url(#arrowhead)');
path.exit().remove();
var pathInvis = svg.selectAll("path.invis")
.data(force.links());
pathInvis.enter().append("svg:path")
.attr("id", function (d) {
return "invis_" + d.source.id + "-" + d.value + "-" + d.target.id;
})
.attr("class", "invis");
pathInvis.exit().remove();
var pathLabel = svg.selectAll(".pathLabel")
.data(force.links());
pathLabel.enter().append("g").append("svg:text")
.attr("class", "pathLabel")
.append("svg:textPath")
.attr("startOffset", "50%")
.attr("text-anchor", "middle")
.attr("xlink:href", function(d) { return "#invis_" + d.source.id + "-" + d.value + "-" + d.target.id; })
.style("fill", "#cccccc")
.style("font-size", 10)
.text(function(d) { return d.value; });
var node = svg.selectAll("g.node")
.data(force.nodes());
var nodeEnter = node.enter().append("g")
.attr("class", "node")
.call(force.drag);
nodeEnter.append("svg:circle")
.attr("r", 10)
.attr("id", function (d) {
return "Node;" + d.id;
})
.attr("class", "nodeStrokeClass")
.attr("fill", "#0db7ed")
nodeEnter.append("svg:text")
.attr("class", "textClass")
.attr("x", 20)
.attr("y", ".31em")
.text(function (d) {
return d.label;
});
node.exit().remove();
function arcPath(leftHand, d) {
var x1 = leftHand ? d.source.x : d.target.x,
y1 = leftHand ? d.source.y : d.target.y,
x2 = leftHand ? d.target.x : d.source.x,
y2 = leftHand ? d.target.y : d.source.y,
dx = x2 - x1,
dy = y2 - y1,
dr = Math.sqrt(dx * dx + dy * dy),
drx = dr,
dry = dr,
sweep = leftHand ? 0 : 1;
siblingCount = countSiblingLinks(d.source, d.target)
xRotation = 0,
largeArc = 0;
if (siblingCount > 1) {
var siblings = getSiblingLinks(d.source, d.target);
console.log(siblings);
var arcScale = d3.scale.ordinal()
.domain(siblings)
.rangePoints([1, siblingCount]);
drx = drx/(1 + (1/siblingCount) * (arcScale(d.value) - 1));
dry = dry/(1 + (1/siblingCount) * (arcScale(d.value) - 1));
}
return "M" + x1 + "," + y1 + "A" + drx + ", " + dry + " " + xRotation + ", " + largeArc + ", " + sweep + " " + x2 + "," + y2;
}
force.on("tick", function(e) {
var q = d3.geom.quadtree(nodes),
i = 0,
n = nodes.length,
k = .1 * e.alpha;
while (++i < n) q.visit(collide(nodes[i]));
node.attr("transform", function (d) {
return "translate(" + d.x + "," + d.y + ")";
});
path.attr("d", function(d) {
return arcPath(true, d);
});
pathInvis.attr("d", function(d) {
return arcPath(d.source.x < d.target.x, d);
});
});
force
.charge(-10000)
.friction(0.5)
.linkDistance(linkDistance)
.size([w, h])
.start();
keepNodesOnTop();
}
update();
function collide(node) {
var r = node.radius + 16,
nx1 = node.x - r,
nx2 = node.x + r,
ny1 = node.y - r,
ny2 = node.y + r;
return function(quad, x1, y1, x2, y2) {
if (quad.point && (quad.point !== node)) {
var x = node.x - quad.point.x,
y = node.y - quad.point.y,
l = Math.sqrt(x * x + y * y),
r = node.radius + quad.point.radius;
if (l < r) {
l = (l - r) / l * .5;
node.x -= x *= l;
node.y -= y *= l;
quad.point.x += x;
quad.point.y += y;
}
}
return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
};
}
}
function drawGraph() {
graph = new myGraph();
graph.initialize();
}
drawGraph();
function keepNodesOnTop() {
$(".nodeStrokeClass").each(function( index ) {
var gNode = this.parentNode;
gNode.parentNode.appendChild(gNode);
});
}
</script>
@Rudgey84
Copy link

I've used this in my d3 force directive graph. I'm getting a little trouble with the position of the paths generated. E.g. I have 3 links between two nodes, for some reason I can only see two paths as two of the paths overlap each other exactly. I believe something to do with dry = dry / (0.4 + (1 / siblingCount) * (arcScale(d.label) - 1)); in the arcPath function. Appreciate if you can figure out what I've done wrong here: StackBlitz example

@mattkohl-flex
Copy link

@Rudgey84, I'm probably missing something, but I don't see anything wrong with your code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment