xkcd-style Pollster charts
Code via http://dan.iel.fm/xkcd
xkcd-style Pollster charts
Code via http://dan.iel.fm/xkcd
<!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; | |
} |