Skip to content

Instantly share code, notes, and snippets.

@cdermont
Last active July 10, 2018 16:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cdermont/846051eb548e846eac60 to your computer and use it in GitHub Desktop.
Save cdermont/846051eb548e846eac60 to your computer and use it in GitHub Desktop.
Party Flow Sankey

Sankey Diagram based on party votes in the Swiss 2015 elections for the Canton of Grison

In Switzerland, we elect by proportional vote with open lists, i.e., you can change your personal ballot as much as you want. The consequence is that candidates from various parties may end up on the same list with the header of a given party. So, it is possible to observe how certain parties lost and gained votes from other parties through this adaption of lists, as each vote for a candidate is counted as a vote for his/her party.

The Sankey Diagram is a very intuitive way of visualizing this vote flows from one party to another:

  • on the left side, you see the source of a vote, i.e., the party that figurated on top of a ballot,
  • on the right side, you see the 'target' of a vote, i.e., which party actually received this vote. So the right side proportion is the final result of the election,
  • the flows show how the candidates got mixed and thus party votes got exchanged.

In the making of I profited from d3noobs' Tips and Tricks both for Sankey and Tooltips, Mike Bostocks Blocks and of course Tom Counsells Sankey library. While it is surely not perfect, I am really happy with the thing. My efforts included layout and the fade-on-mouseover-effect for all linked elements. Working example including German analysis on my Blog, where I also implemented a responsive version.

Data source: Canton of Grison. All work with data has been made with R.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
path {
stroke: white;
stroke-width: 0.25px;
fill: lightgrey;
}
.graph {
font-family: sans-serif;
font-size: 11px;
}
.nodesankey rect {
cursor: pointer;
fill-opacity: .9;
shape-rendering: crispEdges;
}
.nodesankey text {
pointer-events: none;
}
.linksankey {
fill: none;
stroke: #000;
stroke-opacity: .6;
}
.linksankey:hover {
stroke-opacity: .9;
}
div.tooltipsankey {
position: absolute;
text-align: left;
width: 95px;
height: 40px;
padding: 4px;
font: 11px sans-serif;
background: silver;
border: 0px;
border-radius: 8px;
pointer-events: none;
}
</style>
<body>
<script src="https://d3js.org/d3.v3.min.js"></script>
<script src="sankey.js"></script>
<div id="chart"></div>
<script>
// sankey //
var units = "Stimmen";
var aspect = 0.8;
var margin = {top: 10, right: 40, bottom: 10, left: 40},
height = 500 - margin.top - margin.bottom,
width = (height+margin.top+margin.bottom)/aspect - margin.left - margin.right;
var formatNumber = d3.format(",.0f"),
format = function(d) { return formatNumber(d) + " " + units; },
color = d3.scale.category20();
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("class", "svgchart")
.append("g")
.attr("transform",
"translate(" + margin.left + "," + margin.top + ")");
var sankey = d3.sankey()
.nodeWidth(30)
.nodePadding(17)
.size([width, height]);
var path = sankey.link();
var div = d3.select("body").append("div")
.attr("class", "tooltipsankey")
.style("opacity", 0);
var color = d3.scale.ordinal()
.domain(["SVP", "FDP", "CVP", "BDP", "GLP", "SP", "Ohne", "SVP.", "FDP.", "CVP.", "BDP.", "GLP.", "SP.", "Leer."])
.range(["yellowgreen", "darkblue", "orange", "gold", "lawngreen", "firebrick", "grey", "yellowgreen", "darkblue", "orange", "gold", "lawngreen", "firebrick", "grey"]);
var rect
var node
var link
d3.csv("sankeygr2015.csv", function(error, data) {
daten = data
graph = {"nodes" : [], "links" : []};
data.forEach(function (d) {
graph.nodes.push({ "name": d.source });
graph.nodes.push({ "name": d.target });
graph.links.push({ "source": d.source,
"target": d.target,
"color": d.color,
"value": +d.value });
});
graph.nodes = d3.keys(d3.nest()
.key(function (d) { return d.name; })
.map(graph.nodes));
graph.links.forEach(function (d, i) {
graph.links[i].source = graph.nodes.indexOf(graph.links[i].source);
graph.links[i].target = graph.nodes.indexOf(graph.links[i].target);
});
graph.nodes.forEach(function (d, i) {
graph.nodes[i] = { "name": d };
});
sankey
.nodes(graph.nodes)
.links(graph.links)
.layout(32);
link = svg.append("g").selectAll(".link")
.data(graph.links)
.enter().append("path")
.attr("class", "linksankey")
.attr("d", path)
.attr("id", function(d) { return "link" + d.source.name; })
.style("stroke-width", function(d) { return Math.max(1, d.dy); })
.style("stroke", function(d) { return d.color; });
link.on("mouseover", function(d) {
div.transition()
.duration(200)
.style("opacity", .9);
div .html("<b>" + d.source.name + "</b> → <b>" + d.target.name + "</b><br/>" + format(d.value))
.style("left", (d3.event.pageX) + "px")
.style("top", (d3.event.pageY - 28) + "px");
})
.on("mouseout", function(d) {
div.transition()
.duration(500)
.style("opacity", 0);
});
node = svg.append("g").selectAll(".node")
.data(graph.nodes)
.enter().append("g")
.attr("class", "nodesankey")
.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")"; });
rect = node.append("rect")
.attr("height", function(d) { return d.dy; })
.attr("width", sankey.nodeWidth())
.style("fill", function(d) { return color(d.name); });
rect.on("mouseover", function(d) {
div.transition()
.duration(200)
.style("opacity", .9);
div .html("<b>" + d.name + "</b>:<br/>" + format(d.value))
.style("left", (d3.event.pageX) + "px")
.style("top", (d3.event.pageY - 28) + "px");
})
.on("mouseout", function(d) {
div.transition()
.duration(500)
.style("opacity", 0);
});
node.append("text")
.attr("x", 40)
.attr("y", function(d) { return d.dy / 2; })
.attr("dy", ".35em")
.attr("text-anchor", "start")
.attr("transform", null)
.text(function(d) { return d.name; })
.attr("class", "graph")
.filter(function(d) { return d.x < width / 2; })
.attr("x", -40 + sankey.nodeWidth())
.attr("text-anchor", "end");
// Fade-Effect on mouseover
node.on("mouseover", function(d) {
link.transition()
.duration(700)
.style("opacity", .1);
link.filter(function(s) { return d.name == s.source.name; }).transition()
.duration(700)
.style("opacity", 1);
link.filter(function(t) { return d.name == t.target.name; }).transition()
.duration(700)
.style("opacity", 1);
})
.on("mouseout", function(d) { svg.selectAll(".linksankey").transition()
.duration(700)
.style("opacity", 1)} );
});
</script>
</body>
</html>
// Where not otherwise covered by separate copyright, this source code is Copyright Thomas Counsell (c) 2010, 2011.
// https://tamc.github.io/Sankey/
// Where not otherwise covered by a separate licence, this source code is distributed under the MIT licence: http://www.opensource.org/licenses/mit-license.php
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
d3.sankey = function() {
var sankey = {},
nodeWidth = 24,
nodePadding = 8,
size = [1, 1],
nodes = [],
links = [];
sankey.nodeWidth = function(_) {
if (!arguments.length) return nodeWidth;
nodeWidth = +_;
return sankey;
};
sankey.nodePadding = function(_) {
if (!arguments.length) return nodePadding;
nodePadding = +_;
return sankey;
};
sankey.nodes = function(_) {
if (!arguments.length) return nodes;
nodes = _;
return sankey;
};
sankey.links = function(_) {
if (!arguments.length) return links;
links = _;
return sankey;
};
sankey.size = function(_) {
if (!arguments.length) return size;
size = _;
return sankey;
};
sankey.layout = function(iterations) {
computeNodeLinks();
computeNodeValues();
computeNodeBreadths();
computeNodeDepths(iterations);
computeLinkDepths();
return sankey;
};
sankey.relayout = function() {
computeLinkDepths();
return sankey;
};
sankey.link = function() {
var curvature = .5;
function link(d) {
var x0 = d.source.x + d.source.dx,
x1 = d.target.x,
xi = d3.interpolateNumber(x0, x1),
x2 = xi(curvature),
x3 = xi(1 - curvature),
y0 = d.source.y + d.sy + d.dy / 2,
y1 = d.target.y + d.ty + d.dy / 2;
return "M" + x0 + "," + y0
+ "C" + x2 + "," + y0
+ " " + x3 + "," + y1
+ " " + x1 + "," + y1;
}
link.curvature = function(_) {
if (!arguments.length) return curvature;
curvature = +_;
return link;
};
return link;
};
// Populate the sourceLinks and targetLinks for each node.
// Also, if the source and target are not objects, assume they are indices.
function computeNodeLinks() {
nodes.forEach(function(node) {
node.sourceLinks = [];
node.targetLinks = [];
});
links.forEach(function(link) {
var source = link.source,
target = link.target;
if (typeof source === "number") source = link.source = nodes[link.source];
if (typeof target === "number") target = link.target = nodes[link.target];
source.sourceLinks.push(link);
target.targetLinks.push(link);
});
}
// Compute the value (size) of each node by summing the associated links.
function computeNodeValues() {
nodes.forEach(function(node) {
node.value = Math.max(
d3.sum(node.sourceLinks, value),
d3.sum(node.targetLinks, value)
);
});
}
// Iteratively assign the breadth (x-position) for each node.
// Nodes are assigned the maximum breadth of incoming neighbors plus one;
// nodes with no incoming links are assigned breadth zero, while
// nodes with no outgoing links are assigned the maximum breadth.
function computeNodeBreadths() {
var remainingNodes = nodes,
nextNodes,
x = 0;
while (remainingNodes.length) {
nextNodes = [];
remainingNodes.forEach(function(node) {
node.x = x;
node.dx = nodeWidth;
node.sourceLinks.forEach(function(link) {
nextNodes.push(link.target);
});
});
remainingNodes = nextNodes;
++x;
}
//
moveSinksRight(x);
scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
}
function moveSourcesRight() {
nodes.forEach(function(node) {
if (!node.targetLinks.length) {
node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1;
}
});
}
function moveSinksRight(x) {
nodes.forEach(function(node) {
if (!node.sourceLinks.length) {
node.x = x - 1;
}
});
}
function scaleNodeBreadths(kx) {
nodes.forEach(function(node) {
node.x *= kx;
});
}
function computeNodeDepths(iterations) {
var nodesByBreadth = d3.nest()
.key(function(d) { return d.x; })
.sortKeys(d3.ascending)
.entries(nodes)
.map(function(d) { return d.values; });
//
initializeNodeDepth();
resolveCollisions();
for (var alpha = 1; iterations > 0; --iterations) {
relaxRightToLeft(alpha *= .99);
resolveCollisions();
relaxLeftToRight(alpha);
resolveCollisions();
}
function initializeNodeDepth() {
var ky = d3.min(nodesByBreadth, function(nodes) {
return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value);
});
nodesByBreadth.forEach(function(nodes) {
nodes.forEach(function(node, i) {
node.y = i;
node.dy = node.value * ky;
});
});
links.forEach(function(link) {
link.dy = link.value * ky;
});
}
function relaxLeftToRight(alpha) {
nodesByBreadth.forEach(function(nodes, breadth) {
nodes.forEach(function(node) {
if (node.targetLinks.length) {
var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
node.y += (y - center(node)) * alpha;
}
});
});
function weightedSource(link) {
return center(link.source) * link.value;
}
}
function relaxRightToLeft(alpha) {
nodesByBreadth.slice().reverse().forEach(function(nodes) {
nodes.forEach(function(node) {
if (node.sourceLinks.length) {
var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
node.y += (y - center(node)) * alpha;
}
});
});
function weightedTarget(link) {
return center(link.target) * link.value;
}
}
function resolveCollisions() {
nodesByBreadth.forEach(function(nodes) {
var node,
dy,
y0 = 0,
n = nodes.length,
i;
// Push any overlapping nodes down.
nodes.sort(ascendingDepth);
for (i = 0; i < n; ++i) {
node = nodes[i];
dy = y0 - node.y;
if (dy > 0) node.y += dy;
y0 = node.y + node.dy + nodePadding;
}
// If the bottommost node goes outside the bounds, push it back up.
dy = y0 - nodePadding - size[1];
if (dy > 0) {
y0 = node.y -= dy;
// Push any overlapping nodes back up.
for (i = n - 2; i >= 0; --i) {
node = nodes[i];
dy = node.y + node.dy + nodePadding - y0;
if (dy > 0) node.y -= dy;
y0 = node.y;
}
}
});
}
function ascendingDepth(a, b) {
return a.y - b.y;
}
}
function computeLinkDepths() {
nodes.forEach(function(node) {
node.sourceLinks.sort(ascendingTargetDepth);
node.targetLinks.sort(ascendingSourceDepth);
});
nodes.forEach(function(node) {
var sy = 0, ty = 0;
node.sourceLinks.forEach(function(link) {
link.sy = sy;
sy += link.dy;
});
node.targetLinks.forEach(function(link) {
link.ty = ty;
ty += link.dy;
});
});
function ascendingSourceDepth(a, b) {
return a.source.y - b.source.y;
}
function ascendingTargetDepth(a, b) {
return a.target.y - b.target.y;
}
}
function center(node) {
return node.y + node.dy / 2;
}
function value(link) {
return link.value;
}
return sankey;
};
source target value color
SVP SVP. 76569 yellowgreen
FDP SVP. 1933 darkblue
BDP SVP. 1707 gold
CVP SVP. 1491 orange
GLP SVP. 380 lawngreen
SP SVP. 308 firebrick
Ohne SVP. 7708 grey
SVP FDP. 2415 yellowgreen
FDP FDP. 25626 darkblue
BDP FDP. 3177 gold
CVP FDP. 1413 orange
GLP FDP. 679 lawngreen
SP FDP. 824 firebrick
Ohne FDP. 5490 grey
SVP BDP. 1480 yellowgreen
FDP BDP. 1566 darkblue
BDP BDP. 32100 gold
CVP BDP. 1551 orange
GLP BDP. 787 lawngreen
SP BDP. 1111 firebrick
Ohne BDP. 4948 grey
SVP CVP. 2330 yellowgreen
FDP CVP. 1723 darkblue
BDP CVP. 3505 gold
CVP CVP. 35464 orange
GLP CVP. 803 lawngreen
SP CVP. 1309 firebrick
Ohne CVP. 5753 grey
SVP GLP. 530 yellowgreen
FDP GLP. 659 darkblue
BDP GLP. 1953 gold
CVP GLP. 819 orange
GLP GLP. 13738 lawngreen
SP GLP. 2812 firebrick
Ohne GLP. 3162 grey
SVP SP. 526 yellowgreen
FDP SP. 587 darkblue
BDP SP. 2143 gold
CVP SP. 1085 orange
GLP SP. 1998 lawngreen
SP SP. 42001 firebrick
Ohne SP. 4652 grey
Ohne Leer. 2227 grey
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment