A d3.js visualization showing trending Twitter words during natural catastrophes by distance from the epicenter and over time.
Built on top of bunkat's Swimlane Chart: http://bl.ocks.org/1962173
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>A River of Twitter Data</title> | |
<script src="http://d3js.org/d3.v2.min.js"></script> | |
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script> | |
<script type="text/javascript" src="https://raw.github.com/jaz303/tipsy/master/src/javascripts/jquery.tipsy.js"></script> | |
<script type="text/javascript" src="http://f.cl.ly/items/2U0A2k0d1O2e0F2Y143F/jquery-ui.js"></script> | |
<link rel="stylesheet" href="./style.css" type="text/css" /> | |
<link rel="stylesheet" href="./tipsy.css" type="text/css" /> | |
<link rel="stylesheet" href="http://code.jquery.com/ui/1.9.1/themes/base/jquery-ui.css" /> | |
</head> | |
<body> | |
<div> | |
<h1> | |
<img src="http://f.cl.ly/items/3S462I0S3v1M2F3d1L2V/twitter-bird-white-on-blue1.png"/> | |
Activity, Hurricane Sandy | |
<!-- play & pause buttons, hosted externally because Stanford doesn't seem to like these files on cgi-bin --> | |
<span><img class='playbutton' type="button" value="Play" id="play" src="http://f.cl.ly/items/3O2M2V2w2a0j0s1I2I32/Play.png"/></span> | |
<span><img class='playbutton' type="button" value="Stop" id="stop" style="display:none" src="http://f.cl.ly/items/3v452T2g0i0i1V1C113O/pause.png"/></span> | |
</h1> | |
</div> | |
<script type="text/javascript"> | |
// distance bins | |
var distanceBins = [["0-50 Miles","Southern New Jersey"],["51-110 Miles","Includes Manhattan"],["111-200 Miles","Includes Washington D.C."],["201-500 Miles","Entire East Coast"],["501-1000 Miles","Midwest Region (including Chicago)"],["1001-3000 Miles","Continental U.S."],["≥ 3000 Miles","Rest of the World"]]; | |
// for universal access, switchData contains the whole json file | |
var jsonData; | |
var jsonDataRT; | |
var jsonDataFilter; | |
var jsonDataRTFilter; | |
var switchData; | |
var checkData = 0; | |
// these control the user's potential selection range for brushing, too large a difference yields a scrunched graph | |
var max_extent_width = 10 | |
, min_extent_width = 5; | |
// min/max values of d.positivity, d.freqBin, and d.objectivity | |
var positivityDomain = [0.0163966168908, 0.0418471720818] | |
, frequencyDomain = [1,4] | |
, objectivityDomain = [0.923212665835, 0.965169775079]; | |
// vars for decreasing blueness (by increasing RG values) of heat maps | |
var redOffset = 0; | |
var greenOffset = 0; | |
d3.json("./sandyData.json", function (json) { | |
jsonData = json; | |
jsonDataRT = json; | |
switchData = json; | |
jsonDataFilter = switchData.filter(function(d) {return d.isRT == 0}); | |
jsonDataRTFilter = switchData.filter(function(d) {return d.isRT == 1}); | |
switchData = jsonDataFilter; | |
var margin = {top: 20, right: 15, bottom: 15, left: 105} | |
, width = 960 - margin.left - margin.right | |
, height = 500 - margin.top - margin.bottom | |
, miniHeight = distanceBins.length * 12 + 50 | |
, mainHeight = height - miniHeight - 50; | |
x = d3.scale.linear() | |
.domain([0,164]) | |
.range([1, width]); | |
x1 = d3.scale.linear().range([1, width]); | |
var ext = d3.extent(distanceBins, function(d,i) { return i; }); | |
y1 = d3.scale.linear().domain([ext[0], ext[1] + 1]).range([0, mainHeight]); | |
y2 = d3.scale.linear().domain([ext[0], ext[1] + 1]).range([0, miniHeight]); | |
//heat maps for main and mini graph (sets blue (RG) B value) | |
colorScale = d3.scale.quantize() | |
.domain(positivityDomain) | |
.range([0,75,150,225]); // color will be arranged into four quartiles with quantize | |
// font size scale from 12 to 20 | |
wordSizeScale = d3.scale.quantize() | |
.domain(frequencyDomain) | |
.range([12,14,16,18]); | |
// resizes fonts if user expands brush selection | |
extentSizeScale = d3.scale.linear() | |
.domain([min_extent_width,max_extent_width]) | |
.range([2,4]); | |
// determines if bins are emotional enough to be italic | |
typeFaceScale = d3.scale.linear() | |
.domain(objectivityDomain) | |
.range([0,3]); | |
var chart = d3.select('body') | |
.append('svg:svg') | |
.attr('width', width + margin.right + margin.left+105) | |
.attr('height', height + margin.top + margin.bottom) | |
.attr('class', 'chart'); | |
chart.append('defs').append('clipPath') | |
.attr('id', 'clip') | |
.append('rect') | |
.attr('width', width) | |
.attr('height', mainHeight); | |
main = chart.append('g') | |
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') | |
.attr('width', width) | |
.attr('height', mainHeight) | |
.attr('class', 'main'); | |
mini = chart.append('g') | |
.attr('transform', 'translate(' + margin.left + ',' + (mainHeight + 60) + ')') | |
.attr('width', width) | |
.attr('height', miniHeight) | |
.attr('class', 'mini'); | |
// draw the distanceBins for the main chart | |
main.append('g').selectAll('.laneLines') | |
.data(distanceBins) | |
.enter().append('line') | |
.attr('x1', 0) | |
.attr('y1', function(d,i) { return d3.round(y1(i)) + 1; }) | |
.attr('x2', width) | |
.attr('y2', function(d,i) { return d3.round(y1(i)) + 1; }) | |
.attr('stroke', function(d,i) { return 'lightgray' }); | |
main.append('g').selectAll('.laneText') | |
.data(distanceBins) | |
.enter().append('text') | |
.text(function(d) { return d[0]; }) | |
.attr('x',-5) | |
.attr('y', function(d,i) { return y1(i + .5); }) | |
.attr('dy', '0.5ex') | |
.attr('text-anchor', 'end') | |
.attr('class', 'laneText') | |
.attr('id', 'laneHover') | |
.style('font-family', 'Rockwell') | |
.on('mouseover',function() { | |
$('svg #laneHover').tipsy({ | |
gravity: 'nw', | |
html: true, | |
title: function() { | |
var d = this.__data__; | |
return d[1]; | |
} | |
}) | |
}); | |
// x-axis label | |
main.append('text') | |
.attr('class', 'x_label') | |
.attr('id', 'x_label') | |
.attr('text-anchor', 'end') | |
.attr('x', width+70) | |
.attr('y', mainHeight+5) | |
.style('font-family', 'Rockwell') | |
.text('hours from'); | |
main.append('text') | |
.attr('class', 'x_label') | |
.attr('id', 'x_label') | |
.attr('text-anchor', 'end') | |
.attr('x', width+70) | |
.attr('y', mainHeight + 19) | |
.style('font-family', 'Rockwell') | |
.text('landfall'); | |
// sloppy legend - how does one wrap SVG text? | |
main.append('svg:text') | |
.attr('x', width+7) | |
.attr('y', 15) | |
.style('font-family', 'Rockwell') | |
.style('font-size',12) | |
.text('Lighter shades of'); | |
main.append('svg:text') | |
.attr('x', width+7) | |
.attr('y', 30) | |
.style('font-family', 'Rockwell') | |
.style('font-size',12) | |
.text('blue correspond to'); | |
main.append('svg:text') | |
.attr('x', width+7) | |
.attr('y', 45) | |
.style('font-family', 'Rockwell') | |
.style('font-size',12) | |
.text('higher positivity.'); | |
main.append('svg:text') | |
.attr('x', width+7) | |
.attr('y', 60) | |
.style('font-family', 'Rockwell') | |
.style('font-size',12) | |
.text('(Sentiwordnet'); | |
main.append('svg:text') | |
.attr('x', width+7) | |
.attr('y', 75) | |
.style('font-family', 'Rockwell') | |
.style('font-size',12) | |
.text('sentiment score)'); | |
// toggle tweets on and off | |
main.append('rect') | |
.attr('id','toggle') | |
.attr('x', width+7) | |
.attr('y', 88) | |
.attr('width',90) | |
.attr('height',20) | |
.style('fill','#4099FF') | |
.on('mouseup', switchAllData) | |
.attr('rx',5) | |
.attr('ry',5); | |
main.append('svg:text') | |
.attr('id', 'retweet-toggle') | |
.attr('x', width+15) | |
.attr('y', 102) | |
.text('Retweets: Off') // Toggles between on and off | |
.style('font-family', 'Rockwell') | |
.style('fill','white') | |
.style('font-size',12) | |
.style('pointer-events','none'); | |
// draw the distanceBins for the mini chart | |
mini.append('g').selectAll('.laneLines') | |
.data(distanceBins) | |
.enter().append('line') | |
.attr('x1', 0) | |
.attr('y1', function(d,i) { return d3.round(y2(i)) + 0.5; }) | |
.attr('x2', width) | |
.attr('y2', function(d,i) { return d3.round(y2(i)) + 0.5; }) | |
.attr('stroke', function(d) { return d === '' ? 'white' : 'lightgray' }); | |
mini.append('g').selectAll('.laneText') | |
.data(distanceBins) | |
.enter().append('svg:text') | |
.text(function(d) { return d[0]; }) | |
.attr('x', -10) | |
.attr('y', function(d,i) { return y2(i+ 0.5); }) | |
.attr('dy', '0.5ex') | |
.attr('text-anchor', 'end') | |
.attr('class', 'laneText'); | |
// draw the x-axes | |
xDateAxis = d3.svg.axis() | |
.scale(x1) | |
.ticks(20) | |
.tickSize(15, 0, 0); | |
x1DateAxis = d3.svg.axis() | |
.scale(x) | |
.ticks(20) | |
.tickSize(15, 0, 0); | |
main.append('g') | |
.attr('transform', 'translate(0,' + mainHeight + ')') | |
.attr('class', 'main axis') | |
.call(xDateAxis); | |
mini.append('g') | |
.attr('transform', 'translate(0,125)') | |
.attr('class', 'axis') | |
.call(x1DateAxis) | |
.selectAll('text') | |
.attr('dx', 9) | |
.attr('dy', 12); | |
// declare the main rectangles to be amended in display() | |
itemRects = main.append('g') | |
.attr('clip-path', 'url(#clip)'); | |
// draw mini graph | |
mini.append('g').selectAll('miniItems') | |
.data(switchData) | |
.enter().append('rect') | |
.attr('class', function(d) { return 'miniItem '; }) | |
.attr("x",function(d,i) {return x(d.time_bin)}) | |
.attr("y",function(d) {return y2(d.dist_bin+.08)}) | |
.attr("width",x(2)-x(1)) | |
.attr("height",y2(1)*.5) | |
.style('fill', function(d) { | |
var b = 255-(Math.round(colorScale(d.positivity))); | |
return "rgb(" + redOffset + "," + greenOffset + "," + b + ")"; | |
}); | |
// invisible rect for when brush is moved by click instead of drag | |
mini.append('rect') | |
.attr('pointer-events', 'painted') | |
.attr('width', width) | |
.attr('height', miniHeight) | |
.attr('visibility', 'hidden') | |
.on('mouseup', moveBrush); | |
// draw the selection area | |
brush = d3.svg.brush() | |
.x(x) | |
.extent([0,5]) | |
.on("brush", display) | |
mini.append('g') | |
.attr('class', 'x brush') | |
.call(brush) | |
.selectAll('rect') | |
.attr('y', 1) | |
.attr('id','brushHover') | |
.attr('height', miniHeight - 1); | |
mini.selectAll('rect.background').remove(); | |
display(); | |
}); | |
// check to see which dataset is currently displayed, and if button is clicked, switch dataset | |
function switchAllData() { | |
if (checkData == 0) { | |
main.select('#retweet-toggle') | |
.text('Retweets: On'); | |
main.select('#toggle') | |
.transition().duration(500) | |
.style('fill','#009900'); | |
checkData = 1; | |
switchData = jsonDataRTFilter; | |
} else { | |
main.select('#retweet-toggle') | |
.text('Retweets: Off'); | |
main.select('#toggle') | |
.transition().duration(500) | |
.style('fill','#4099FF'); | |
checkData = 0; | |
switchData = jsonDataFilter; | |
} | |
display(); | |
} | |
function display () { | |
var rects, labels | |
, minExtent = brush.extent()[0] | |
, maxExtent = brush.extent()[1]; | |
var extentWidth = maxExtent - minExtent; | |
if (extentWidth > max_extent_width) | |
maxExtent = minExtent + max_extent_width; | |
if (extentWidth < min_extent_width) | |
maxExtent = minExtent + min_extent_width; | |
var visItems = switchData.filter(function (d) {return d.time_bin <= maxExtent+1 && d.time_bin >= minExtent-1}); | |
mini.select('.brush').call(brush.extent([minExtent, maxExtent])); | |
x1.domain([minExtent, maxExtent]); | |
if ((maxExtent - minExtent) >= 10) { | |
xDateAxis.ticks(10); | |
} | |
else { | |
xDateAxis.ticks(5); | |
} | |
// update the axis | |
main.select('.main.axis').call(xDateAxis) | |
.selectAll('text') | |
.attr('dx', 5) | |
.attr('dy', 12); | |
// upate the item rects | |
rects = itemRects.selectAll('rect') | |
.data(switchData, function (d,i) { return i; }) | |
.attr('x', function(d) { return x1(d.time_bin); }) | |
.attr('y', function(d) { return y1(d.dist_bin) + .1 * y1(1) + 0.5; }) | |
.attr('width', 165); | |
rects.enter().append('svg:rect') | |
.attr('x', function(d) { return x1(d.time_bin); }) | |
.attr('y', function(d) { return y1(d.dist_bin) + .1 * y1(1) + 0.5; }) | |
.attr('width', 165) | |
.attr('class','borderMe') | |
.attr('height', function(d) { return .8 * y1(1); }) | |
.style('fill', function(d) { | |
var b = 255-(Math.round(colorScale(d.positivity))); | |
return "rgb(" + redOffset + "," + greenOffset + "," + b + ")"; | |
}); | |
rects.exit().remove(); | |
// update the item labels | |
labels = itemRects.selectAll('text') | |
.data(visItems, function (d) { return d.word; }) | |
.text(function (d) { return d.word; }) | |
.attr('x', function(d) { | |
return Math.round(x1(d.time_bin+0.05)); | |
}) | |
.attr('y', function(d) { return y1(d.dist_bin) + .6 * y1(1)}) | |
.attr('id','hover') | |
.style('font-size', 14); | |
labels.enter().append('svg:text') | |
.text(function (d) { return d.word; }) | |
.attr('x', function(d) { | |
return Math.round(x1(d.time_bin+0.05)); | |
}) | |
.attr('y', function(d) { return y1(d.dist_bin) + .6 * y1(1) }) | |
.attr('text-anchor', 'start') | |
.attr('class', 'itemLabel') | |
.attr('id','hover') | |
.style('font-size', 14) | |
.style('font-family', 'Arial') | |
.style('fill','white') | |
.on('mouseup',function(d) { | |
var selectedWord = d.word; | |
highlightWords(selectedWord); | |
}); | |
labels.order(); | |
labels.exit().remove(); | |
function highlightWords (a) { | |
mini.selectAll('miniItems') | |
.data(switchData) | |
.enter().append('rect') | |
.attr('class', function(d) { return 'miniItem '; }) | |
.attr("x",function(d,i) {return x(d.time_bin)}) | |
.attr("y",function(d) {return y2(d.dist_bin+.08)}) | |
.attr("width",x(2)-x(1)) | |
.attr("height",y2(1)*.5) | |
.style('fill', function(d) { | |
var b = Math.round(colorScale(d.positivity)); | |
if (d.word == a) { | |
return "rgb(0,200,0)"; | |
} | |
else { | |
var b = 255-(Math.round(colorScale(d.positivity))); | |
return "rgb(" + redOffset + "," + greenOffset + "," + b + ")"; | |
} | |
}) | |
rects = itemRects.selectAll('rect') | |
.data(switchData) | |
.transition().duration(1000) | |
.style('fill', function(d) { | |
var b = 255-(Math.round(colorScale(d.positivity))); | |
if (d.word == a) { | |
return "rgb(" + redOffset + ",200," + greenOffset + ")"; | |
} | |
}) | |
.transition().delay(2000).style('fill', function(d) { | |
var b = 255-(Math.round(colorScale(d.positivity))); | |
return "rgb(" + redOffset + "," + greenOffset + "," + b + ")"; | |
}); | |
} | |
// tipsy hover states by element ID | |
$('svg #hover').tipsy({ | |
gravity: 'w', | |
html: true, | |
title: function() { | |
var d = this.__data__; | |
return d.sample_tweet; | |
} | |
}); | |
$('svg #x_label').tipsy({ | |
gravity: 'ne', | |
html: true, | |
title: function() { | |
return "0 Hour is LandFall"; | |
} | |
}) | |
$('svg #typeFace').tipsy({ | |
gravity: 'ne', | |
html: true, | |
title: function() { | |
return "<span style='font-family: Arial; font-style: italic'>Italic is highly emotional,</span>" + "<br>" + "<span style='font-family: Arial'>No styling is a normal level of emotion.</span>" + "<br>" + "<span style='font-family: Arial Black; font-weight:bold'>Bold is removed of emotion.</span>"; | |
} | |
}) | |
$('svg #color').tipsy({ | |
gravity: 'ne', | |
html: true, | |
title: function() { | |
return "Lighter shades correlate to higher positivity. (Sentiwordnet sentiment score)"; | |
} | |
}) | |
$('svg #frequency').tipsy({ | |
gravity: 'ne', | |
html: true, | |
title: function() { | |
return "Larger fonts correlate to higher relative word frequency."; | |
} | |
}) | |
$('svg #brushHover').tipsy({ | |
gravity: 'sw', | |
html: true, | |
title: function() { | |
return "Drag or expand me to scroll through data."; | |
} | |
}) | |
$('svg #toggle').tipsy({ | |
gravity: 'ne', | |
html: true, | |
fontSize: 10, | |
title: function() { | |
return "Click here to add or remove retweets from the data."; | |
} | |
}) | |
$('.tipsy:last').remove(); | |
}; | |
// for clicking brush to a location instead of dragging | |
function moveBrush () { | |
var origin = d3.mouse(this) | |
, point = x.invert(origin[0]) | |
, halfExtent = (brush.extent()[1] - brush.extent()[0]) / 2 | |
, start = point - halfExtent | |
, end = point + halfExtent; | |
brush.extent([start,end]); | |
display(); | |
} | |
// Set up auto play parameters | |
var timer = null, | |
duration = 100, | |
extentIncrement = 0.05, | |
extentMax = 164; | |
// Define behavior of the Play button | |
$('#play').click(function() { | |
var extent = brush.extent(); | |
if (extent[1] >= extentMax) { | |
extent[1] -= extent[0]; | |
extent[0] = 0; | |
brush.extent(extent); | |
} | |
display(); | |
if (timer) { | |
clearInterval(timer); | |
} | |
timer = setInterval(function() { | |
var extent = brush.extent(); | |
if (extent[1] <= extentMax) { | |
extent[0] += extentIncrement; | |
extent[1] += extentIncrement; | |
display(); | |
brush.extent(extent); | |
} | |
else { | |
$('#stop').click(); | |
} | |
}, duration); | |
$(this).hide(); | |
$('#stop').show(); | |
}); | |
$('#stop').click(function() { | |
if (timer) { | |
clearInterval(timer); | |
} | |
$(this).hide(); | |
$('#play').show(); | |
}); | |
</script> | |
</body> | |
</html> |
A d3.js visualization showing trending Twitter words during natural catastrophes by distance from the epicenter and over time.
Built on top of bunkat's Swimlane Chart: http://bl.ocks.org/1962173