Another experiment along the line of this one. This time, links are drawn in a way that the overlap near the node is removed. The method is somehow reminiscent of Sankey diagrams (see this paper for similar considerations, applied to arc diagrams).
Besides reducing visual clutter, two advantages of this design are:
(function() {
var MAX_WIDTH, TENSION_SLIDER_Y, brushed, circular, circular_layout, compute_degree, graph_data, handle, height, labels, link_thickness, links, links_layer, list_links, max, nodes, nodes_layer, objectify, radius, redraw, sankey, slider, svg, tension, tension_brush, tension_scale, width;
graph_data = {
nodes: [
{
id: 'A',
size: 14
}, {
id: 'B',
size: 56
}, {
id: 'C',
size: 26
}, {
id: 'D',
size: 16
}, {
id: 'E',
size: 32
}, {
id: 'F',
size: 16
}, {
id: 'G',
size: 12
}
],
links: [
{
source: 'A',
target: 'B',
weight: 12
}, {
source: 'A',
target: 'C',
weight: 2
}, {
source: 'A',
target: 'D',
weight: 33
}, {
source: 'A',
target: 'F',
weight: 5
}, {
source: 'A',
target: 'G',
weight: 24
}, {
source: 'B',
target: 'D',
weight: 10
}, {
source: 'B',
target: 'E',
weight: 10
}, {
source: 'B',
target: 'F',
weight: 8
}, {
source: 'B',
target: 'G',
weight: 16
}, {
source: 'C',
target: 'D',
weight: 29
}, {
source: 'C',
target: 'E',
weight: 11
}, {
source: 'D',
target: 'E',
weight: 4
}, {
source: 'D',
target: 'F',
weight: 12
}, {
source: 'E',
target: 'F',
weight: 19
}
]
};
objectify = function(graph) {
return graph.links.forEach(function(l) {
return graph.nodes.forEach(function(n) {
if (l.source === n.id) {
l.source = n;
}
if (l.target === n.id) {
return l.target = n;
}
});
});
};
objectify(graph_data);
list_links = function(graph) {
return graph.nodes.forEach(function(n) {
n.links = graph.links.filter(function(link) {
return link.source === n || link.target === n;
});
return n.links.sort(function(a, b) {
return a.weight - b.weight;
});
});
};
list_links(graph_data);
sankey = function(graph) {
return graph.nodes.forEach(function(n) {
var acc;
acc = 0;
return n.links.forEach(function(link) {
if (link.source === n) {
return link.sankey_source = {
start: acc,
middle: acc + link.weight / 2,
end: acc += link.weight
};
} else if (link.target === n) {
return link.sankey_target = {
start: acc,
middle: acc + link.weight / 2,
end: acc += link.weight
};
}
});
});
};
sankey(graph_data);
compute_degree = function(graph) {
return graph.nodes.forEach(function(n) {
return n.degree = d3.sum(n.links, function(link) {
return link.weight;
});
});
};
compute_degree(graph_data);
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
svg.attr({
viewBox: "" + (-width / 2) + " " + (-height / 2 - 30) + " " + width + " " + height
});
circular_layout = function() {
var delta_theta, rho, self, theta, theta_0;
rho = function(d, i, data) {
return 100;
};
theta_0 = function(d, i, data) {
return -Math.PI / 2;
};
delta_theta = function(d, i, data) {
return 2 * Math.PI / data.length;
};
theta = function(d, i, data) {
return theta_0(d, i, data) + i * delta_theta(d, i, data);
};
self = function(data) {
data.forEach(function(d, i) {
d.rho = rho(d, i, data);
d.theta = theta(d, i, data);
d.x = d.rho * Math.cos(d.theta);
return d.y = d.rho * Math.sin(d.theta);
});
return data;
};
self.rho = function(x) {
if (x != null) {
if (typeof x === 'function') {
rho = x;
} else {
rho = function() {
return x;
};
}
return self;
}
return rho;
};
self.theta_0 = function(x) {
if (x != null) {
if (typeof x === 'function') {
theta_0 = x;
} else {
theta_0 = function() {
return x;
};
}
return self;
}
return theta_0;
};
self.delta_theta = function(x) {
if (x != null) {
if (typeof x === 'function') {
delta_theta = x;
} else {
delta_theta = function() {
return x;
};
}
return self;
}
return delta_theta;
};
self.theta = function(x) {
if (x != null) {
if (typeof x === 'function') {
theta = x;
} else {
theta = function() {
return x;
};
}
return self;
}
return theta;
};
return self;
};
circular = circular_layout().rho(160);
circular(graph_data.nodes);
MAX_WIDTH = 40;
links_layer = svg.append('g');
nodes_layer = svg.append('g');
radius = d3.scale.sqrt().domain([
0, d3.min(graph_data.nodes, function(n) {
return n.size;
})
]).range([0, MAX_WIDTH / 2]);
nodes = nodes_layer.selectAll('.node').data(graph_data.nodes);
nodes.enter().append('circle').attr({
"class": 'node',
r: function(node) {
return radius(node.size);
},
cx: function(node) {
return node.x + (4 + radius(node.size)) * Math.cos(node.theta);
},
cy: function(node) {
return node.y + (4 + radius(node.size)) * Math.sin(node.theta);
}
});
labels = nodes_layer.selectAll('.label').data(graph_data.nodes);
labels.enter().append('text').text(function(node) {
return node.id;
}).attr({
"class": 'label',
dy: '0.35em',
x: function(node) {
return node.x + (4 + radius(node.size)) * Math.cos(node.theta);
},
y: function(node) {
return node.y + (4 + radius(node.size)) * Math.sin(node.theta);
}
});
max = d3.max(graph_data.nodes, function(n) {
return n.degree;
});
link_thickness = d3.scale.linear().domain([0, max]).range([0, MAX_WIDTH]);
links = links_layer.selectAll('.link').data(graph_data.links);
tension = 0;
links.enter().append('path').attr({
"class": 'link',
'stroke-width': function(link) {
return link_thickness(link.weight);
}
});
redraw = function() {
return links.attr({
d: function(link) {
var cxs, cxt, cys, cyt, sankey_ds, sankey_dt, sankey_dxs, sankey_dxt, sankey_dys, sankey_dyt, xs, xt, ys, yt;
sankey_ds = link_thickness(link.source.degree) / 2 - link_thickness(link.sankey_source.middle);
sankey_dt = link_thickness(link.target.degree) / 2 - link_thickness(link.sankey_target.middle);
sankey_dxs = sankey_ds * Math.cos(link.source.theta + Math.PI / 2);
sankey_dys = sankey_ds * Math.sin(link.source.theta + Math.PI / 2);
sankey_dxt = sankey_dt * Math.cos(link.target.theta + Math.PI / 2);
sankey_dyt = sankey_dt * Math.sin(link.target.theta + Math.PI / 2);
xs = link.source.x + sankey_dxs;
ys = link.source.y + sankey_dys;
xt = link.target.x + sankey_dxt;
yt = link.target.y + sankey_dyt;
cxs = xs - link.source.x * tension;
cys = ys - link.source.y * tension;
cxt = xt - link.target.x * tension;
cyt = yt - link.target.y * tension;
return "M" + xs + " " + ys + " C" + cxs + " " + cys + " " + cxt + " " + cyt + " " + xt + " " + yt;
}
});
};
tension_scale = d3.scale.linear().domain([0, 1]).range([-width / 2 + 50, width / 2 - 50]).clamp(true);
tension_brush = d3.svg.brush().x(tension_scale).extent([0, 0]);
TENSION_SLIDER_Y = -240;
svg.append('g').attr('class', 'x axis').attr('transform', "translate(0," + TENSION_SLIDER_Y + ")").call(d3.svg.axis().scale(tension_scale).orient('bottom').tickFormat(function(d) {
return d;
}).tickSize(0).tickPadding(12)).select('.domain').select(function() {
return this.parentNode.appendChild(this.cloneNode(true));
}).attr('class', 'halo');
slider = svg.append('g').attr('class', 'slider').call(tension_brush);
slider.selectAll('.extent,.resize').remove();
slider.select('.background').attr('transform', "translate(0," + (TENSION_SLIDER_Y - 11) + ")").attr('height', 22);
handle = slider.append('circle').attr('class', 'handle').attr('transform', "translate(0," + TENSION_SLIDER_Y + ")").attr('r', 9);
tension_brush.extent([0.1, 0.1]);
slider.call(tension_brush.event).transition().duration(1800).call(tension_brush.extent([0.4, 0.4])).call(tension_brush.event);
brushed = function() {
tension = tension_brush.extent()[0];
if (d3.event.sourceEvent) {
tension = tension_scale.invert(d3.mouse(this)[0]);
tension_brush.extent([tension, tension]);
}
handle.attr('cx', tension_scale(tension));
return redraw();
};
tension_brush.on('brush', brushed);
}).call(this);
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="Node-link circular layout: Sankey links" />
<title>Node-link circular layout: Sankey links</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="//d3js.org/d3.v3.min.js"></script>
</head>
<body>
<svg height="500" width="960"></svg>
<script src="index.js"></script>
</body>
</html>
# data
graph_data = {
nodes: [
{id: 'A', size: 14},
{id: 'B', size: 56},
{id: 'C', size: 26},
{id: 'D', size: 16},
{id: 'E', size: 32},
{id: 'F', size: 16},
{id: 'G', size: 12}
],
links: [
{source: 'A', target: 'B', weight: 12},
{source: 'A', target: 'C', weight: 2},
{source: 'A', target: 'D', weight: 33},
{source: 'A', target: 'F', weight: 5},
{source: 'A', target: 'G', weight: 24},
{source: 'B', target: 'D', weight: 10},
{source: 'B', target: 'E', weight: 10},
{source: 'B', target: 'F', weight: 8},
{source: 'B', target: 'G', weight: 16},
{source: 'C', target: 'D', weight: 29},
{source: 'C', target: 'E', weight: 11},
{source: 'D', target: 'E', weight: 4},
{source: 'D', target: 'F', weight: 12},
{source: 'E', target: 'F', weight: 19}
]
}
# objectify the graph
# resolve node IDs (not optimized at all!)
objectify = (graph) ->
graph.links.forEach (l) ->
graph.nodes.forEach (n) ->
if l.source is n.id
l.source = n
if l.target is n.id
l.target = n
objectify(graph_data)
# create link arrays for each node
list_links = (graph) ->
graph.nodes.forEach (n) ->
n.links = graph.links.filter (link) -> link.source is n or link.target is n
# sort in decreasing weight order
n.links.sort (a,b) -> a.weight-b.weight
list_links(graph_data)
# sankeify the graph
sankey = (graph) ->
graph.nodes.forEach (n) ->
acc = 0
n.links.forEach (link) ->
if link.source is n
link.sankey_source = {
start: acc,
middle: acc + link.weight/2,
end: acc += link.weight
}
else if link.target is n
link.sankey_target = {
start: acc,
middle: acc + link.weight/2,
end: acc += link.weight
}
sankey(graph_data)
# compute node weighted degrees (sankey totals)
compute_degree = (graph) ->
graph.nodes.forEach (n) ->
n.degree = d3.sum n.links, (link) -> link.weight
compute_degree(graph_data)
svg = d3.select('svg')
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
# translate the viewBox to have (0,0) at the center of the vis
svg
.attr
viewBox: "#{-width/2} #{-height/2-30} #{width} #{height}"
# layout
circular_layout = () ->
rho = (d, i, data) -> 100
theta_0 = (d, i, data) -> -Math.PI/2 # start from the angle pointing north
delta_theta = (d, i, data) -> 2*Math.PI/data.length
theta = (d, i , data) -> theta_0(d, i, data) + i*delta_theta(d, i, data)
self = (data) ->
data.forEach (d, i) ->
d.rho = rho(d, i, data)
d.theta = theta(d, i, data)
d.x = d.rho * Math.cos(d.theta)
d.y = d.rho * Math.sin(d.theta)
return data
self.rho = (x) ->
if x?
if typeof(x) is 'function'
rho = x
else
rho = () -> x
return self
# else
return rho
self.theta_0 = (x) ->
if x?
if typeof(x) is 'function'
theta_0 = x
else
theta_0 = () -> x
return self
# else
return theta_0
self.delta_theta = (x) ->
if x?
if typeof(x) is 'function'
delta_theta = x
else
delta_theta = () -> x
return self
# else
return delta_theta
self.theta = (x) ->
if x?
if typeof(x) is 'function'
theta = x
else
theta = () -> x
return self
# else
return theta
return self
# apply the layout
circular = circular_layout()
.rho(160)
circular(graph_data.nodes)
MAX_WIDTH = 40
# draw nodes above links
links_layer = svg.append('g')
nodes_layer = svg.append('g')
radius = d3.scale.sqrt()
.domain([0, d3.min graph_data.nodes, (n) -> n.size])
.range([0, MAX_WIDTH/2])
nodes = nodes_layer.selectAll('.node')
.data(graph_data.nodes)
nodes.enter().append('circle')
.attr
class: 'node'
r: (node) -> radius(node.size)
cx: (node) -> node.x + (4+radius(node.size))*Math.cos(node.theta)
cy: (node) -> node.y + (4+radius(node.size))*Math.sin(node.theta)
# draw node labels
labels = nodes_layer.selectAll('.label')
.data(graph_data.nodes)
labels.enter().append('text')
.text((node) -> node.id)
.attr
class: 'label'
dy: '0.35em'
x: (node) -> node.x + (4+radius(node.size))*Math.cos(node.theta)
y: (node) -> node.y + (4+radius(node.size))*Math.sin(node.theta)
max = d3.max graph_data.nodes, (n) -> n.degree
link_thickness = d3.scale.linear()
.domain([0, max])
.range([0, MAX_WIDTH])
links = links_layer.selectAll('.link')
.data(graph_data.links)
tension = 0
links.enter().append('path')
.attr
class: 'link'
'stroke-width': (link) -> link_thickness(link.weight)
redraw = () ->
links
.attr
d: (link) ->
sankey_ds = link_thickness(link.source.degree)/2 - link_thickness(link.sankey_source.middle)
sankey_dt = link_thickness(link.target.degree)/2 - link_thickness(link.sankey_target.middle)
sankey_dxs = sankey_ds*Math.cos(link.source.theta+Math.PI/2)
sankey_dys = sankey_ds*Math.sin(link.source.theta+Math.PI/2)
sankey_dxt = sankey_dt*Math.cos(link.target.theta+Math.PI/2)
sankey_dyt = sankey_dt*Math.sin(link.target.theta+Math.PI/2)
xs = link.source.x + sankey_dxs
ys = link.source.y + sankey_dys
xt = link.target.x + sankey_dxt
yt = link.target.y + sankey_dyt
cxs = xs-link.source.x*tension
cys = ys-link.source.y*tension
cxt = xt-link.target.x*tension
cyt = yt-link.target.y*tension
return "M#{xs} #{ys} C#{cxs} #{cys} #{cxt} #{cyt} #{xt} #{yt}"
# draw the controls
tension_scale = d3.scale.linear()
.domain([0, 1])
.range([-width/2+50, width/2-50])
.clamp(true)
tension_brush = d3.svg.brush()
.x(tension_scale)
.extent([0, 0])
TENSION_SLIDER_Y = -240
svg.append('g')
.attr('class', 'x axis')
.attr('transform', "translate(0,#{TENSION_SLIDER_Y})")
.call(d3.svg.axis()
.scale(tension_scale)
.orient('bottom')
.tickFormat((d) -> d)
.tickSize(0)
.tickPadding(12))
.select('.domain')
.select(() -> this.parentNode.appendChild(this.cloneNode(true)) )
.attr('class', 'halo')
slider = svg.append('g')
.attr('class', 'slider')
.call(tension_brush)
slider.selectAll('.extent,.resize')
.remove()
slider.select('.background')
.attr('transform', "translate(0,#{TENSION_SLIDER_Y-11})")
.attr('height', 22)
handle = slider.append('circle')
.attr('class', 'handle')
.attr('transform', "translate(0,#{TENSION_SLIDER_Y})")
.attr('r', 9)
# initial animation
tension_brush.extent([0.1, 0.1])
slider
.call(tension_brush.event)
.transition()
.duration(1800)
.call(tension_brush.extent([0.4, 0.4]))
.call(tension_brush.event)
brushed = () ->
tension = tension_brush.extent()[0]
if d3.event.sourceEvent # not a programmatic event
tension = tension_scale.invert(d3.mouse(this)[0])
tension_brush.extent([tension, tension])
handle.attr('cx', tension_scale(tension))
# redraw the links
redraw()
tension_brush
.on('brush', brushed)
svg {
background: white;
}
.node {
fill: lightgray;
stroke: gray;
stroke-width: 2px;
}
.link {
stroke: black;
fill: none;
opacity: 0.1;
}
.label {
text-anchor: middle;
font-size: 16px;
fill: #444;
font-weight: bold;
font-family: sans-serif;
}
.axis {
font: 10px sans-serif;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.axis .domain {
fill: none;
stroke: #000;
stroke-opacity: .3;
stroke-width: 10px;
stroke-linecap: round;
}
.axis .halo {
fill: none;
stroke: #ddd;
stroke-width: 8px;
stroke-linecap: round;
}
.slider .handle {
fill: #fff;
stroke: #000;
stroke-opacity: .5;
stroke-width: 1.25px;
pointer-events: none;
}