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="http://f.cl.ly/items/2U0A2k0d1O2e0F2Y143F/jquery-ui.js"></script> | |
| <script type="text/javascript" src="./tipsy.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