Skip to content

Instantly share code, notes, and snippets.

@gilmoreorless
Last active June 30, 2016 07:47
Show Gist options
  • Save gilmoreorless/f1a4920277d714b8ab8bb01c5b64d525 to your computer and use it in GitHub Desktop.
Save gilmoreorless/f1a4920277d714b8ab8bb01c5b64d525 to your computer and use it in GitHub Desktop.
Senate "How to Vote" connections Ⅱ
height: 700
license: cc-by-4.0

An attempt to visualise the Senate preference connections between candidate parties for the 2016 Australian election.

A follow-up to Senate "How to Vote" connections Ⅰ.

New voting rules are in place for the 2016 election, which means that Senate parties no longer submit a full list of party preferences to the Australian Electoral Commission. The only way to determine a party’s preferences is to look at their “How to Vote” suggestions. The data for this visualisation come from the “How to Vote” preferences for parties in NSW, as collated by Antony Green.

Not all parties running for the Senate are shown here — only those where data is available (either giving or receiving preferences). Parties that deliberately don’t give preferences and haven’t received any other preferences are shown as unattached circles (e.g. Nick Xenophon Team (NXT)).

Description

This is an attempt to algorithmically sort parties into vague “left-wing” and “right-wing” groups. I wanted to see if party similarities could be automatically determined just by looking at preference connections.

Senate candidate parties (in NSW) are sorted left-to-right, with the most nominally “left-wing”/liberal (in the true sense of the word) parties further to the left, and the most nominally “right-wing”/conservative parties further to the right. The parties are then lumped into two groups for left-of-centre or right-of-centre, with preference connections still shown. Any parties without preference connections remain floating in the middle.

The result is close to what I wanted, but doesn’t quite match up with reality. Really this comes down to trying to arbitrarily categorise complex human politics. For the most part the connections are what I’d expect, but there are some unexpected preference links between parties that skew the data (such as the right-wing One Nation Party giving a preference to the left-wing Animal Justice Party).

Even though it’s not quite right, I’m putting this online to at least show the attempt. The node colours don’t mean anything, they’re just evenly spaced samples from a colour hue wheel to make it look prettier.

How it works

All positions are derived from the data alone. The only manual classification was nominating two parties as the “anchor points”, by choosing a known left-wing party and a known right-wing party. All other calculations are then performed relative to those two reference points.

  1. For each “anchor point”, all parties are given a “proximity ranking” based on how closely associated they are with that party:
  2. Rankings are based on percentage, where the “anchor point” party is ranked a 100% match, and the percentage drops as assocations move further away.
  3. All parties with direct preference links to the “anchor point” are given a ranking related to the strength of the preference. For example, a party listed as the first preference of the “anchor point” is ranked slightly higher than the party listed as a fifth preference. All parties that have given their preferences to the “anchor point” are also ranked based on their preference order, but at a lower weighting (simply put – preferences from other parties are slightly less important than preferences to other parties).
  4. Once all the direct preference links to the “anchor point” have been exhausted, the process is repeated, but this time the reference points are the linked parties from the “anchor point”. (For example, in the first round, party A is the reference, and parties B, C, D are linked. In the next round of checks, all remaining parties are checked for links to parties B, C, D.)
  5. This process repeats until there are no more links to check. For each “step” of links away from the “anchor point”, the ranking is reduced on a logarithmic scale, to dilute the strength of links as connections move further away.
  6. The two lists (ranked-by-left-connection, ranked-by-right-connection) are then merged, where a party’s final position is the average of the two rankings.
  7. Any party with a final ranking of “left-of-centre” is put in a circle, with position from the left side of the screen dependent on the proximity ranking. The same happens for “right-of-centre” parties in the other circle. Parties with no links are put by themselves in the middle.
{
"parties": {
"SEC": "Secular Party of Australia",
"NXT": "Nick Xenophon Team",
"PPA": "Pirate Party Australia",
"SA": "Socialist Alliance",
"ASX": "Australian Sex Party",
"HEMP": "Marijuana (HEMP) Party",
"SCI": "Science Party",
"CYC": "Cyclists Party",
"ART": "Arts Party",
"GRN": "Greens",
"AJP": "Animal Justice Party",
"AP": "Australian Progressives",
"VEP": "Voluntary Euthanasia Party",
"DLR": "Drug Law Reform",
"REP": "Renewable Energy Party",
"ALP": "Australian Labor Party",
"JLN": "Jacqui Lambie Network",
"LNP": "Liberals and Nationals",
"AMEP": "Australian Motoring Enthusiast Party",
"ONP": "Pauline Hanson's One Nation",
"RUA": "Rise Up Australia Party",
"FFP": "Family First",
"DLP": "Democratic Labour Party",
"LD": "Liberal Democrats",
"KAP": "Katter's Australian Party",
"ALA": "Australian Liberty Alliance",
"CDP": "Christian Democratic Party",
"SFF": "Shooters, Fishers and Farmers",
"NCPP": "Non-Custodial Parents Party",
"VP": "Veterans Party",
"SUS": "Sustainable Australia",
"DHJP": "Derryn Hinch Justice Party",
"SUP": "Seniors United Party",
"CM": "CountryMinded",
"VFO": "VoteFlux.org",
"HAP": "Health Australia Party",
"SEQ": "Socialist Equality Party"
},
"knownLeft": "ASX",
"knownRight": "CDP",
"states": {
"nsw": {
"FFP": ["DLP", "LD", "KAP", "ALA", "CDP"],
"LD": ["SFF", "ASX", "FFP", "ALA", "KAP"],
"LNP": ["CDP", "SFF", "FFP", "LD", "AMEP"],
"DLP": ["FFP", "CDP", "KAP", "SFF", "HAP"],
"SCI/CYC": ["ART", "GRN", "ASX", "AJP", "ALP"],
"SFF": ["CDP", "ALA", "RUA", "LD", "LNP"],
"SA": ["GRN", "AP", "VEP", "DLR", "HEMP", "AJP", "ALP"],
"RUA": ["ONP", "CDP", "DLP", "SFF", "FFP"],
"ALP": ["GRN", "REP", "AJP", "ASX", "LD"],
"DHJP": [],
"JLN": [],
"PPA": ["GRN", "ASX", "SCI/CYC", "ALP", "REP"],
"ONP": ["RUA", "AJP", "DLP", "ALA", "NCPP"],
"VP": ["DHJP", "SUP", "CYC", "SCI", "CM"],
"SEQ": [],
"AJP": ["SUS", "REP", "HAP", "GRN", "ALP"],
"NCPP": [],
"ASX": ["HEMP", "VEP", "REP", "SEC", "AJP"],
"AP": [],
"NXT": [],
"SUS": ["AJP", "REP"],
"GRN": ["PPA", "SCI/CYC", "SA", "AJP", "ALP"],
"ALA": ["FFP", "SFF", "KAP", "ONP", "LD"],
"REP": ["AJP", "SUS", "ASX", "GRN", "ALP", "HEMP", "VFO", "SCI"]
}
}
}
<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
font-family: sans-serif;
margin: auto;
position: relative;
width: 960px;
}
.node {
cursor: pointer;
}
.node circle {
fill: #987;
stroke: #fff;
stroke-width: 1.5px;
}
.party-text {
fill: #fff;
font-size: 0.8125em;
text-anchor: middle;
}
.link {
fill: #999;
fill-opacity: .9;
stroke: #999;
stroke-opacity: .6;
}
</style>
<body>
<script src="//d3js.org/d3.v3.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script>
var width = 960,
height = 700,
radius = 20;
var inPrefsScale = 0.5;
var totalScale = 1 / (1 + inPrefsScale);
var prefDistanceDecay = d3.scale.log().range([1, 0]);
var svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height);
function flatten(arr) {
return arr.reduce(function (list, item) {
return list.concat(item.reduce ? flatten(item) : item);
}, []);
}
function partitionByAssocation(nodes, targetParty) {
var hasAssociation = function (pref) {
return pref.party === targetParty;
};
return _.partition(nodes, function (node) {
return node.short === targetParty || _.some(node.outPrefs, hasAssociation) || _.some(node.inPrefs, hasAssociation);
});
}
function partyComparitor(targetParty) {
return function (a, b) {
if (a.short === targetParty) return -1;
if (b.short === targetParty) return 1;
return getPrefWeight(b, targetParty) - getPrefWeight(a, targetParty);
}
}
function getPrefWeight(node, targetParty) {
var findWeight = function (prefs) {
return (_.findWhere(prefs, {party: targetParty}) || {}).weight || 0;
};
if (node.short === targetParty) {
return 1;
}
return (findWeight(node.outPrefs) + findWeight(node.inPrefs) * inPrefsScale) * totalScale;
}
function averagePrefWeight(node, targetParties) {
var weights = _.values(_.pick(node.prefWeights, targetParties));
return d3.mean(weights) || 0;
}
function sortByProximitySingle(nodes, targetParty) {
var clumps = [];
var clumpIndex = 0;
var targetParties = [targetParty];
var linkSplit = _.partition(nodes, function (node) {
return node.outPrefs.length || node.inPrefs.length;
});
var remainingNodes = linkSplit[0];
var noLinkNodes = linkSplit[1];
var prevClump, prevLen, clumpWeight;
while (remainingNodes.length) {
targetParties.forEach(function (target) {
// Split into [<linked to target>, <not linked to target>]
var splitNodes = partitionByAssocation(remainingNodes, target);
(clumps[clumpIndex] || (clumps[clumpIndex] = [])).push(splitNodes[0]);
remainingNodes = splitNodes[1];
});
// Make sure the clump is a single array of nodes
prevClump = clumps[clumpIndex] ? flatten(clumps[clumpIndex]) : [];
// Calculate a proximity score for each node based on links and distance
clumpWeight = prefDistanceDecay(clumpIndex + 1);
prevClump.forEach(function (node) {
var score = node.short === targetParty ? 1 : averagePrefWeight(node, targetParties);
node.proximity[targetParty] = score * clumpWeight;
});
// Safeguard to short-circuit the loop if nothing has changed - can happen with bad data
if (remainingNodes.length === prevLen) {
break;
}
// Update the list of party IDs for the next comparison loop
targetParties = prevClump.map(function (node) { return node.short; });
prevLen = remainingNodes.length;
clumpIndex++;
}
clumps.push(noLinkNodes);
return clumps;
}
function balanceProximites(nodes, targetLeft, targetRight) {
nodes.forEach(function (node) {
var left = node.proximity[targetLeft] || 0;
var right = node.proximity[targetRight] || 0;
node.proximity.total = (left + 1 - right) / 2;
});
return _.sortBy(nodes, function (node) {
return 1 - node.proximity.total;
});
}
function sortByProximity(nodes, targetLeft, targetRight) {
sortByProximitySingle(nodes, targetLeft);
sortByProximitySingle(nodes, targetRight);
return balanceProximites(nodes, targetLeft, targetRight);
}
// [1, 2, 3, 4, 5, 6, 7] => [1, 3, 5, 7, 6, 4, 2]
function rearrange(nodes) {
var len = nodes.length;
var left = [], right = [];
nodes.forEach(function (node, i) {
(i % 2 ? right : left).push(node);
});
return left.concat(right.reverse());
}
function makeCircle(nodes, cx, cy, radius) {
var angleRads = Math.PI * 2 / nodes.length;
return rearrange(nodes).map(function (node, i) {
var angle = angleRads * i - Math.PI / 2;
node.x = Math.sin(angle) * radius + cx;
node.y = Math.cos(angle) * radius + cy;
return node;
});
}
function circulate(leftNodes, midNodes, rightNodes) {
var leftX = width * 0.25;
var midX = width * 0.5;
var rightX = width * 0.75;
var y = height * 0.5;
var largeRadius = width / 5;
midNodes.forEach(function (node, i) {
node.x = midX;
node.y = (i + 3) * (radius * 2 + 10);
});
return [].concat(
makeCircle(leftNodes, leftX, y, largeRadius),
midNodes,
makeCircle(rightNodes, rightX, y, largeRadius)
);
}
var nodeMap = d3.map();
d3.json('data.json', function (err, rawData) {
if (err) throw err;
var links = [];
var len = Object.keys(rawData.parties).length;
// Map party names to node objects
var nodes = Object.keys(rawData.parties).map(function (key, i) {
var node = {
short: key,
long: rawData.parties[key],
x: i / len * width,// / 2 + width / 4,
y: Math.random() * height,
outPrefs: [],
inPrefs: [],
prefWeights: {},
proximity: {}
};
nodeMap.set(key, node);
return node;
});
// Create link objects for each defined preference
Object.keys(rawData.states.nsw).forEach(function (partyGroup) {
partyGroup.split('/').forEach(function (party) {
links.push(rawData.states.nsw[partyGroup].map(function (prefGroup, i) {
return prefGroup.split('/').map(function (pref) {
var source = nodeMap.get(party);
var target = nodeMap.get(pref);
var weight = (10 - i / 3) / 10;
source.outPrefs.push({party: pref, order: i, weight: weight});
target.inPrefs.push({party: party, order: i, weight: weight});
return {
source: source,
target: target,
weight: weight
};
});
}));
});
});
// Flatten links to single array
links = flatten(links);
// Calculate preference link weights for each node
nodes.forEach(function (node) {
var allParties = _.uniq(_.pluck(node.outPrefs.concat(node.inPrefs), 'party'));
allParties.forEach(function (party) {
node.prefWeights[party] = getPrefWeight(node, party);
});
});
// Sort nodes by connections to known left-/right-wing parties
nodes = sortByProximity(nodes, rawData.knownLeft, rawData.knownRight);
nodes.forEach(function (node, i) {
node.fill = 'hsl(' + (i * 300 / len) + ', 50%, 50%)';
});
var linkSplit = _.partition(nodes, function (node) {
return node.outPrefs.length || node.inPrefs.length;
});
var remainingNodes = linkSplit[0];
var halfIndex = _.findIndex(remainingNodes, function (node) {
return node.proximity.total < 0.51;
});
nodes = circulate(
remainingNodes.slice(0, halfIndex),
linkSplit[1],
remainingNodes.slice(halfIndex)
);
// Render stuff
var link = svg.selectAll('.link')
.data(links)
.enter().append('path')
.attr('class', 'link');
var node = svg.selectAll('.node')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
node.append('circle')
.attr('r', radius)
.style('fill', function (d) { return d.fill; });
node.append('text')
.attr('class', 'party-text')
.attr('dy', '0.35em')
.text(function (d) { return d.short; });
node.append('title')
.text(function (d) { return d.long; });
link.each(positionLink);
node.attr('transform', function(d) { return 'translate(' + [d.x, d.y] + ')'; });
});
function positionLink(d) {
var width = d.weight * 5;
var x = d.target.x - d.source.x;
var y = d.target.y - d.source.y;
var dist = Math.sqrt(x * x + y * y);
var angle = Math.atan2(y, x) / Math.PI * 180;
var w2 = width / 2;
var w3 = width * 0.75;
var coordsBase = [
0, w2,
dist, 0,
0, -w2
];
var coordsHead = [
dist - radius, 0,
dist - radius - 10, -w3,
dist - radius - 10, w3
];
d3.select(this)
.attr('d', 'M0,0 L' + coordsBase + 'z M' + coordsHead.slice(0, 2) + 'L' + coordsHead.slice(2) + 'z')
.attr('transform', 'translate(' + [d.source.x, d.source.y] + ') rotate(' + angle + ')');
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment