Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active February 15, 2019 06:30
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save nitaku/1adb4033d7078f7d005e to your computer and use it in GitHub Desktop.
Save nitaku/1adb4033d7078f7d005e to your computer and use it in GitHub Desktop.
Node-link circular layout: animated Sankey links

Another experiment improving the previous ones. As in this very old experiment, animation is used to convey the direction of links (see Holten et al. 2010 for a comparison of different techniques - animation being the best in our case).

The overlapping of links makes the diagram hard to read, so interaction is used to help the user: By hovering the mouse over a node, it is possible to focus on the links connected to it.

# 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: 8},
{source: 'A', target: 'D', weight: 33},
{source: 'A', target: 'F', weight: 5},
{source: 'A', target: 'G', weight: 24},
{source: 'B', target: 'A', weight: 16},
{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: 'A', weight: 13},
{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},
{source: 'F', target: 'E', weight: 11}
]
}
# 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
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} #{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 = 60
# 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*0.8])
links = links_layer.selectAll('.link')
.data(graph_data.links)
tension = 0.5
links.enter().append('path')
.attr
class: 'link flowline'
'stroke-width': (link) -> link_thickness(link.weight)
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
xsi = xs + (4+radius(link.source.size))*Math.cos(link.source.theta)
ysi = ys + (4+radius(link.source.size))*Math.sin(link.source.theta)
xti = xt + (4+radius(link.target.size))*Math.cos(link.target.theta)
yti = yt + (4+radius(link.target.size))*Math.sin(link.target.theta)
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#{xsi} #{ysi} L#{xs} #{ys} C#{cxs} #{cys} #{cxt} #{cyt} #{xt} #{yt} L#{xti} #{yti}"
# node hover
nodes.on 'mouseover', (n) ->
over_links = svg.selectAll('.link').filter (link) -> link.source isnt n and link.target isnt n
over_links.classed('blurred', true)
nodes.on 'mouseout', () ->
svg.selectAll('.link').classed('blurred', false)
svg {
background: white;
}
.node {
fill: lightgray;
stroke: gray;
stroke-width: 2px;
}
.node:hover {
stroke-width: 3px;
stroke: #555;
}
.link {
stroke: #004;
fill: none;
opacity: 0.2;
}
.blurred.link {
opacity: 0.05;
}
.label {
text-anchor: middle;
font-size: 16px;
fill: #444;
font-weight: bold;
font-family: sans-serif;
pointer-events: none;
}
.flowline {
stroke-dasharray: 78, 2;
animation: flow 6s linear infinite;
-webkit-animation: flow 6s linear infinite;
}
@keyframes flow {
from {
stroke-dashoffset: 80;
}
to {
stroke-dashoffset: 0;
}
}
@-webkit-keyframes flow {
from {
stroke-dashoffset: 80;
}
to {
stroke-dashoffset: 0;
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="description" content="Node-link circular layout: animated Sankey links" />
<title>Node-link circular layout: animated 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, circular, circular_layout, compute_degree, graph_data, height, labels, link_thickness, links, links_layer, list_links, max, nodes, nodes_layer, objectify, radius, sankey, svg, tension, 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: 8
}, {
source: 'A',
target: 'D',
weight: 33
}, {
source: 'A',
target: 'F',
weight: 5
}, {
source: 'A',
target: 'G',
weight: 24
}, {
source: 'B',
target: 'A',
weight: 16
}, {
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: 'A',
weight: 13
}, {
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
}, {
source: 'F',
target: 'E',
weight: 11
}
]
};
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) {
return n.links = graph.links.filter(function(link) {
return link.source === n || link.target === n;
});
});
};
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) + " " + 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 = 60;
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 * 0.8]);
links = links_layer.selectAll('.link').data(graph_data.links);
tension = 0.5;
links.enter().append('path').attr({
"class": 'link flowline',
'stroke-width': function(link) {
return link_thickness(link.weight);
}
});
links.attr({
d: function(link) {
var cxs, cxt, cys, cyt, sankey_ds, sankey_dt, sankey_dxs, sankey_dxt, sankey_dys, sankey_dyt, xs, xsi, xt, xti, ys, ysi, yt, yti;
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;
xsi = xs + (4 + radius(link.source.size)) * Math.cos(link.source.theta);
ysi = ys + (4 + radius(link.source.size)) * Math.sin(link.source.theta);
xti = xt + (4 + radius(link.target.size)) * Math.cos(link.target.theta);
yti = yt + (4 + radius(link.target.size)) * Math.sin(link.target.theta);
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" + xsi + " " + ysi + " L" + xs + " " + ys + " C" + cxs + " " + cys + " " + cxt + " " + cyt + " " + xt + " " + yt + " L" + xti + " " + yti;
}
});
nodes.on('mouseover', function(n) {
var over_links;
over_links = svg.selectAll('.link').filter(function(link) {
return link.source !== n && link.target !== n;
});
return over_links.classed('blurred', true);
});
nodes.on('mouseout', function() {
return svg.selectAll('.link').classed('blurred', false);
});
}).call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment