Skip to content

Instantly share code, notes, and snippets.

@bycoffe
Created October 20, 2012 21:17
Show Gist options
  • Save bycoffe/3924854 to your computer and use it in GitHub Desktop.
Save bycoffe/3924854 to your computer and use it in GitHub Desktop.
xkcd-style Pollster charts in d3
<!DOCTYPE HTML>
<html>
<head>
<title>xkcd-style Pollster plots in d3</title>
<script src="http://code.jquery.com/jquery.min.js"></script>
<script src="http://underscorejs.org/underscore-min.js"></script>
<script src="http://d3js.org/d3.v2.min.js?2.10.0"></script>
<script src="pollster-xkcd.js"></script>
<style>
@font-face {
font-family: "xkcd";
src: url('http://antiyawn.com/uploads/Humor-Sans.ttf');
}
body {
font-family: "xkcd", sans-serif;
font-size: 16px;
color: #333;
}
text.title {
font-size: 20px;
}
path {
fill: none;
stroke-width: 2.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
path.axis {
stroke: black;
}
path.bgline {
stroke: white;
stroke-width: 6px;
}
#chart {
margin-top: 50px;
}
#slug {
width: 300px;
}
#header {
font-family: Helvetica, Arial, sans-serif;
}
</style>
</head>
<body>
<label for="slug">Chart</label>
<select id="slug">
<option value="2012-general-election-romney-vs-obama">2012 General Election: Romney vs. Obama</option>
<option value="obama-favorable-rating">Barack Obama Favorable Rating</option>
<option value="mitt-romney-favorability">Mitt Romney Favorable Rating</option>
<option value="party-identification-adults">Party Identification - Adults</option>
<option value="2012-virginia-senate-allen-vs-kaine">2012 Virginia Senate: Allen vs. Kaine</option>
<option value="2012-missouri-senate-mccaskill-vs-akin">2012 Missouri Senate: McCaskill vs. Akin</option>
<option value="2012-massachusetts-senate-brown-vs-warren">2012 Massachusetts Senate: Brown vs Warren</option>
</select>
<div id="chart"></div>
<script>
var makeGraph = function (slug) {
var url = 'http://elections.huffingtonpost.com/pollster/api/charts/' + slug + '.jsonp';
// Add the lines.
$.ajax(url,
{
dataType: 'jsonp',
jsonpCallback: 'pollsterCallback',
cache: true,
success: function (data) {
// Build the plot.
var plot = xkcdplot({
title: data.title
});
var choices = {};
var parties = {};
var colors = {
'Dem': '#5189b8',
'Democrat': '#5189b8',
'Rep': 'red',
'Republican': 'red',
'Independent': 'green',
'Favorable': 'black',
'Unfavorable': 'red',
};
var ignore = [
'Other',
'Undecided'
];
_(data.estimates).each(function(choice) {
if (_(ignore).indexOf(choice.choice) === -1) {
choices[choice.choice] = [];
parties[choice.choice] = choice.party;
}
});
plot("#chart");
var estimates = data.estimates_by_date.reverse();
_(estimates).each(function(estimate, i) {
_(estimate.estimates).each(function (est) {
if (choices[est.choice]) {
choices[est.choice].push({x: i, y: est.value});
}
});
});
_(choices).each(function(data, choice) {
plot.plot(data, {stroke: colors[parties[choice]] || colors[choice] || 'gray'});
});
// Render the image.
plot.draw();
}
});
}
makeGraph($("#slug").val());
$("#slug").bind('change', function(e) {
$("#chart").html('');
makeGraph($("#slug").val());
});
</script>
</body>
</html>
function xkcdplot(opts) {
// Default parameters.
var width = 600,
height = 300,
margin = 40,
arrowSize = 12,
arrowAspect = 0.4,
arrowOffset = 6,
magnitude = 0.003,
xlabel = "Date",
ylabel = "HuffPost Model Estimate",
title = opts.title,
xlim,
ylim;
// Plot elements.
var el,
xscale = d3.scale.linear(),
yscale = d3.scale.linear();
// Plotting functions.
var elements = [];
// The XKCD object itself.
var xkcd = function (nm) {
el = d3.select(nm).append("svg")
.attr("width", width + 2 * margin)
.attr("height", height + 2 * margin)
.append("g")
.attr("transform", "translate(" + margin + ", "
+ margin + ")");
return xkcd;
};
// Getters and setters.
xkcd.xlim = function () {
if (!arguments.length) return xlim;
xlim = arguments[0];
return xkcd;
};
// Do the render.
xkcd.draw = function () {
// Set the axes limits.
xscale.domain(xlim).range([0, width]);
yscale.domain(ylim).range([height, 100]);
// Compute the zero points where the axes will be drawn.
var x0 = xscale(0),
y0 = yscale(0);
// Draw the axes.
var axis = d3.svg.line().interpolate(xinterp);
el.selectAll(".axis").remove();
el.append("svg:path")
.attr("class", "x axis")
.attr("d", axis([[0, y0], [width, y0]]));
el.append("svg:path")
.attr("class", "y axis")
.attr("d", axis([[x0, 0], [x0, height]]));
// Laboriously draw some arrows at the ends of the axes.
var aa = arrowAspect * arrowSize,
o = arrowOffset,
s = arrowSize;
el.append("svg:path")
.attr("class", "x axis arrow")
.attr("d", axis([[width - s + o, y0 + aa], [width + o, y0], [width - s + o, y0 - aa]]));
el.append("svg:path")
.attr("class", "x axis arrow")
.attr("d", axis([[s - o, y0 + aa], [-o, y0], [s - o, y0 - aa]]));
el.append("svg:path")
.attr("class", "y axis arrow")
.attr("d", axis([[x0 + aa, s - o], [x0, -o], [x0 - aa, s - o]]));
el.append("svg:path")
.attr("class", "y axis arrow")
.attr("d", axis([[x0 + aa, height - s + o], [x0, height + o], [x0 - aa, height - s + o]]));
for (var i = 0, l = elements.length; i < l; ++i) {
var e = elements[i];
e.func(e.data, e.x, e.y, e.opts);
}
// Add some axes labels.
el.append("text").attr("class", "x label")
.attr("text-anchor", "end")
.attr("x", width - s)
.attr("y", y0 + aa)
.attr("dy", ".75em")
.text(xlabel);
el.append("text").attr("class", "y label")
.attr("text-anchor", "end")
.attr("x", aa)
.attr("y", x0)
.attr("dy", "-.75em")
.attr("transform", "rotate(-90)")
.text(ylabel);
// And a title.
el.append("text").attr("class", "title")
.attr("text-anchor", "end")
.attr("x", width)
.attr("y", 0)
.text(title);
return xkcd;
};
// Adding plot elements.
xkcd.plot = function (data, opts) {
var x = function (d) { return d.x; },
y = function (d) { return d.y; },
cx = function (d) { return xscale(x(d)); },
cy = function (d) { return yscale(y(d)); },
xl = d3.extent(data, x),
yl = d3.extent(data, y);
// Rescale the axes.
xlim = xlim || xl;
xlim[0] = Math.min(xlim[0], xl[0]);
xlim[1] = Math.max(xlim[1], xl[1]);
ylim = ylim || yl;
ylim[0] = Math.min(ylim[0], yl[0]);
ylim[1] = Math.max(ylim[1], yl[1]);
// Add the plotting function.
elements.push({
data: data,
func: lineplot,
x: cx,
y: cy,
opts: opts
});
return xkcd;
};
// Plot styles.
function lineplot(data, x, y, opts) {
var line = d3.svg.line().x(x).y(y).interpolate(xinterp),
bgline = d3.svg.line().x(x).y(y),
strokeWidth = _get(opts, "stroke-width", 3),
color = _get(opts, "stroke", "steelblue");
el.append("svg:path").attr("d", bgline(data))
.style("stroke", "white")
.style("stroke-width", 2 * strokeWidth + "px")
.style("fill", "none")
.attr("class", "bgline");
el.append("svg:path").attr("d", line(data))
.style("stroke", color)
.style("stroke-width", strokeWidth + "px")
.style("fill", "none");
};
// XKCD-style line interpolation. Roughly based on:
// jakevdp.github.com/blog/2012/10/07/xkcd-style-plots-in-matplotlib
function xinterp (points) {
// Scale the data.
var f = [xscale(xlim[1]) - xscale(xlim[0]),
yscale(ylim[1]) - yscale(ylim[0])],
z = [xscale(xlim[0]),
yscale(ylim[0])],
scaled = points.map(function (p) {
return [(p[0] - z[0]) / f[0], (p[1] - z[1]) / f[1]];
});
// Compute the distance along the path using a map-reduce.
var dists = scaled.map(function (d, i) {
if (i == 0) return 0.0;
var dx = d[0] - scaled[i - 1][0],
dy = d[1] - scaled[i - 1][1];
return Math.sqrt(dx * dx + dy * dy);
}),
dist = dists.reduce(function (curr, d) { return d + curr; }, 0.0);
// Choose the number of interpolation points based on this distance.
var N = Math.round(200 * dist);
// Re-sample the line.
var resampled = [];
dists.map(function (d, i) {
if (i == 0) return;
var n = Math.max(3, Math.round(d / dist * N)),
spline = d3.interpolate(scaled[i - 1][1], scaled[i][1]),
delta = (scaled[i][0] - scaled[i - 1][0]) / (n - 1);
for (var j = 0, x = scaled[i - 1][0]; j < n; ++j, x += delta)
resampled.push([x, spline(j / (n - 1))]);
});
// Compute the gradients.
var gradients = resampled.map(function (a, i, d) {
if (i == 0) return [d[1][0] - d[0][0], d[1][1] - d[0][1]];
if (i == resampled.length - 1)
return [d[i][0] - d[i - 1][0], d[i][1] - d[i - 1][1]];
return [0.5 * (d[i + 1][0] - d[i - 1][0]),
0.5 * (d[i + 1][1] - d[i - 1][1])];
});
// Normalize the gradient vectors to be unit vectors.
gradients = gradients.map(function (d) {
var len = Math.sqrt(d[0] * d[0] + d[1] * d[1]);
return [d[0] / len, d[1] / len];
});
// Generate some perturbations.
var perturbations = smooth(resampled.map(d3.random.normal()), 3);
// Add in the perturbations and re-scale the re-sampled curve.
var result = resampled.map(function (d, i) {
var p = perturbations[i],
g = gradients[i];
return [(d[0] + magnitude * g[1] * p) * f[0] + z[0],
(d[1] - magnitude * g[0] * p) * f[1] + z[1]];
});
return result.join("L");
}
// Smooth some data with a given window size.
function smooth(d, w) {
var result = [];
for (var i = 0, l = d.length; i < l; ++i) {
var mn = Math.max(0, i - 5 * w),
mx = Math.min(d.length - 1, i + 5 * w),
s = 0.0;
result[i] = 0.0;
for (var j = mn; j < mx; ++j) {
var wd = Math.exp(-0.5 * (i - j) * (i - j) / w / w);
result[i] += wd * d[j];
s += wd;
}
result[i] /= s;
}
return result;
}
// Get a value from an object or return a default if that doesn't work.
function _get(d, k, def) {
if (typeof d === "undefined") return def;
if (typeof d[k] === "undefined") return def;
return d[k];
}
return xkcd;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment