Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active August 29, 2015 14:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nitaku/564fcb398fdad08442c1 to your computer and use it in GitHub Desktop.
Save nitaku/564fcb398fdad08442c1 to your computer and use it in GitHub Desktop.
Node-link circular layout: Sankey links

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:

  • The size of the "strip" near each node is proportional to the node weighted degree centrality (i.e. the sum of the weights of its links), thus encoding a new important variable "for free".
  • The size of each node can thus be used to encode a different quantitative property of the node (usually, it would have been the node degree).
# 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;
}
<!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="http://d3js.org/d3.v3.min.js"></script>
</head>
<body>
<svg height="500" width="960"></svg>
<script src="index.js"></script>
</body>
</html>
(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);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment