block by harrystevens c893c7b441298b36f4568bc09df71a1e

D3 Streamgraph with Tooltip and Legend

Full Screen

A streamgraph showing winners of Indian civilian awards by state over time.

Features:

Libraries used: d3.js, underscore.js, moment.js, [Bootstrap] (https://getbootstrap.com/), and jQuery

Thanks to ColorBrewer for the color array

Based on a block by William Turman

index.html

<!DOCTYPE html>
<html>
  <head>
    
    <!-- meta -->
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!--css-->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
    <link rel="stylesheet" href="styles.css" />

  </head>
  <body>

    <div class="container-fluid">
      <div class="row">
        <div class="col-md-12">
          <div class="row">
            <div class="chart-wrapper col-md-12">
              <div class="chart" column="indiaClass" filterBy="india" groupBy="place"></div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
    <script src="https://d3js.org/d3.v3.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.map"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.15.0/moment.min.js"></script>
    <script src="scripts.js"></script>

  </body>
</html>

scripts.js

// Check breakpoint
function breakCalc(x){
  x <= 480 ? y = 'xs' : y = 'md';
  return y;
}

var breakpoint = breakCalc($(window).width());

$(window).resize(function(){
  var breakpoint = breakCalc($(window).width());
})

// change the height of the chart depending on the breakpoint
function breakHeight(bp){
  bp == 'xs' ? y = 250 : y = 500;
  return y;
}

// function to group by multiple properties in underscore.js
_.groupByMulti = function (obj, values, context) {
    if (!values.length)
        return obj;
    var byFirst = _.groupBy(obj, values[0], context),
        rest = values.slice(1);
    for (var prop in byFirst) {
        byFirst[prop] = _.groupByMulti(byFirst[prop], rest, context);
    }
    return byFirst;
};

// function to decide whether to pluralize the word "award" in the tooltip
function awardPlural(x){
  x == 1 ? y = 'award' : y = 'awards';
  return y;
}

// funciton to determine the century of the datapoint when displaying the tooltip
function century(x){
  x<100 ? y = '19'+x : y = '20'+(x.toString().substring(1));
  return y;
}

// function to ensure the tip doesn't hang off the side
function tipX(x){
  var winWidth = $(window).width();
  var tipWidth = $('.tip').width();
  if (breakpoint == 'xs'){
    x > winWidth - tipWidth - 20 ? y = x-tipWidth : y = x;
  } else {
    x > winWidth - tipWidth - 30 ? y = x-45-tipWidth : y = x+10;
  }
  return y;
}

// function to create the chart
function chart(column, filterBy, groupBy) {

  // basic chart dimensions
  var margin = {top: 20, right: 1, bottom: 30, left: 0};
  var width = $('.chart-wrapper').width() - margin.left - margin.right;
  var height = breakHeight(breakpoint) - margin.top - margin.bottom;

  // chart top used for placing the tooltip
  var chartTop = $('.chart.'+groupBy+'.'+filterBy).offset().top;

  // tooltip
  var tooltip = d3.select("body")
      .append("div")
      .attr("class", "tip")
      .style("position", "absolute")
      .style("z-index", "20")
      .style("visibility", "hidden")
      .style("top", 40+chartTop+"px");

  // scales:
  // x is a time scale, for the horizontal axis
  // y is a linear (quantitative) scale, for the vertical axis
  // z is in ordinal scale, to determine the colors (see var colorrange, below)
  var x = d3.time.scale()
      .range([0, width]);

  var y = d3.scale.linear()
      .range([height-10, 0]);

  // color range provided by colorbrewer
  // i just added a bunch of grays at the end so that the categories grouped as other all appear gray.
  // there's definitely a better way to do this

  var colorrange = ['#66c2a5','#fc8d62','#8da0cb','#e78ac3','#a6d854','#ffd92f','#e5c494','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3','#b3b3b3'];

  var z = d3.scale.ordinal()
      .range(colorrange);

  // the x-axis. note that the ticks are years, and we'll show every 5 years
  var xAxis = d3.svg.axis()
      .scale(x)
      .orient("bottom")
      .ticks(d3.timeYears, 5);

  // stacked layout. the order is reversed to get the largest value on top
  // if you change the order to inside-out, the streams get all mixed up and look cool
  // but the graph is harder to read. reversed order ensures that the streams are in the
  // same order as the legend, which improves readability in lieu of directly labelling
  // the streams (which is another programming challenge entirely)
  var stack = d3.layout.stack()
      .offset("silhouette")
      .order("reverse")
      .values(function(d) { return d.values; })
      .x(function(d) { return d.date; })
      .y(function(d) { return d.value; });

  var nest = d3.nest()
      .key(function(d) { return d.key; });

  // there are some ways other than "basis" to interpolate the area between data points
  // for example, you can use "cardinal", which makes the streams a little more wiggly.
  // the drawback with that approach is that if you have years where there is no data,
  // you won't see a flat line across the center of the chart. instead, it will look all bumpy.
  // ultimately, "cardinal" interpolation is more likely to give an inaccurate represenation of the data,
  // which is anyway a danger with any type of interpolation, including "basis"
  var area = d3.svg.area()
      .interpolate("basis")
      .x(function(d) { return x(d.date); })
      .y0(function(d) { return y(d.y0)-.2; }) // -.2 to create a little space between the layers
      .y1(function(d) { return y(d.y0 + d.y)+.2; }); // +.2, likewise

  var svg = d3.select(".chart."+groupBy+'.'+filterBy).append("svg")
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom)
    .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

  // generate a legend
  function legend(layers){

    // generate the legend title
    function titler(filter,group){

      if (group == 'place') {
        if (filter == 'india'){
          return "State";
        } else {
          return "Country";
        }
      }

    }

    $('.chart.'+groupBy+'.'+filterBy).prepend('<div class="legend"><div class="title">'+titler(filterBy,groupBy)+'</div></div>');
    $('.legend').hide();
    var legend = []
    layers.forEach(function(d,i){
      var obj = {};
      if (i<7){
        obj.key = d.key;
        obj.color = colorrange[i];
        legend.push(obj);
      }
    });

    // others
    if (layers.length>7){legend.push({key: "Other",color: "#b3b3b3"});}

    legend.forEach(function(d,i){
      $('.chart.'+groupBy+'.'+filterBy+' .legend').append('<div class="item"><div class="swatch" style="background: '+d.color+'"></div>'+d.key+'</div>');
    });

    $('.legend').fadeIn();

  }// end legend function

  // parse the data
  function parse(data){

    // this filters and groups the data
    // based on the filters provided in the .chart div (see the html file)
    var filter;
    var searchObj = {};
    searchObj[column] = filterBy;

    if (column=="none"){
      filter=data;
    } else {
      filter = _.where(data,searchObj);
    }

    var categories = _.chain(filter)
        .countBy(groupBy)
        .pairs()
        .sortBy(1).reverse()
        .pluck(0)
        .value();

    var sort = _.sortBy(filter,categories);

    // group by
    var group = _.groupByMulti(sort, ['year', groupBy])

    var newData = [];

    // it is necessary to add an extra year to the data (as well as duplicate the data for the final year)
    // so that the chart does not get cut off on the right side
    for (var i = 1954;i<2018;i++){

      var currYear = group[i];

      // no data for a year
      if (currYear == undefined) {
        currYear = {};
      }

      categories.forEach(function(area){

        var obj = {};
        if (currYear[area] == undefined){
          // if the year does not have any in a particular category
          obj.key = area;
          obj.value = 0;
          obj.date = moment(i.toString())._d;
        } else {
          obj.key = currYear[area][0][groupBy];
          obj.value = currYear[area].length;
          obj.date = moment(currYear[area][0].year)._d;
        }

        newData.push(obj);
      });

    }

    data = newData;// you could just return newData, but this way seems cleaner to me
    return data;
  }

  // now we call the data, as the rest of the code is dependent upon data
  d3.csv("awards.csv", function(data) {

    // parse the data (see parsing function, above)
    data = parse(data);

    // generate our layers
    var layers = stack(nest.entries(data));

    // our legend is based on our layers
    legend(layers);

    // set the domains
    x.domain(d3.extent(data, function(d) { return d.date; }));
    y.domain([0, d3.max(data, function(d) { return d.y0 + d.y; })]);

    // and now we're on to the data joins and appending
    svg.selectAll(".layer")
        .data(layers)
      .enter().append("path")
        .attr("class", "layer")
        .attr("d", function(d) { return area(d.values); })
        .style("fill", function(d, i) { return z(i); });

    svg.append("g")
        .attr("class", "x axis")
        .attr("transform", "translate(0," + height + ")")
        .call(xAxis);

    // abbreviate axis tick text on small screens
    if (breakpoint == 'xs') {

      $('.x.axis text').each(function(){
        var curTxt = $(this).text();
        var newTxt = "'"+curTxt.substr(2);
        $(this).text(newTxt);
      });

    }

    // user interaction with the layers
    svg.selectAll(".layer")
      .attr("opacity", 1)
      .on("mouseover", function(d, i) {
        svg.selectAll(".layer").transition()
          .duration(100)
          .attr("opacity", function(d, j) {
            return j != i ? 0.6 : 1;
      })})
      .on("mousemove", function(d, i) {

        var color = d3.select(this).style('fill'); // need to know the color in order to generate the swatch

        mouse = d3.mouse(this);
        mousex = mouse[0];
        var invertedx = x.invert(mousex);
        var xDate = century(invertedx.getYear());
        d.values.forEach(function(f){
          var year = (f.date.toString()).split(' ')[3];
          if (xDate == year){
              tooltip
                .style("left", tipX(mousex) +"px")
                .html( "<div class='year'>" + year + "</div><div class='key'><div style='background:" + color + "' class='swatch'>&nbsp;</div>" + f.key + "</div><div class='value'>" + f.value + " " + awardPlural((f.value)) + "</div>" )
                .style("visibility", "visible");
          }
        });
      })
      .on("mouseout", function(d, i) {
        svg.selectAll(".layer").transition()
          .duration(100)
          .attr("opacity", '1');
        tooltip.style("visibility", "hidden");
    });

    // vertical line to help orient the user while exploring the streams
    var vertical = d3.select(".chart."+groupBy+'.'+filterBy)
          .append("div")
          .attr("class", "remove")
          .style("position", "absolute")
          .style("z-index", "19")
          .style("width", "2px")
          .style("height", "460px")
          .style("top", "10px")
          .style("bottom", "30px")
          .style("left", "0px")
          .style("background", "#fcfcfc");

    d3.select(".chart."+groupBy+'.'+filterBy)
        .on("mousemove", function(){
           mousex = d3.mouse(this);
           mousex = mousex[0] + 5;
           vertical.style("left", mousex + "px" )})
        .on("mouseover", function(){
           mousex = d3.mouse(this);
           mousex = mousex[0] + 5;
           vertical.style("left", mousex + "px")});

    // Add 'curtain' rectangle to hide entire graph
    var curtain = svg.append('rect')
     .attr('x', -1 * width)
     .attr('y', -1 * height)
     .attr('height', height)
     .attr('width', width)
     .attr('class', 'curtain')
     .attr('transform', 'rotate(180)')
     .style('fill', '#fcfcfc')

    // Create a shared transition for anything we're animating
    var t = svg.transition()
     .delay(100)
     .duration(1500)
     .ease('exp')
     .each('end', function() {
       d3.select('line.guide')
         .transition()
         .style('opacity', 0)
         .remove()
     });

    t.select('rect.curtain')
      .attr('width', 0);
    t.select('line.guide')
      .attr('transform', 'translate(' + width + ', 0)');

  });

}

// get the various arguments from the chart div attributes
// if you're making one chart, this approach is unnecessary
// however, for several stream graphs on one page, this approach is useful
// it allows you to decide how to query the data in the html by assigning various
// attributes to the chart div.
var column = $('.chart').attr("column");
var groupBy = $('.chart').attr("groupBy");
var filterBy = $('.chart').attr("filterBy");
$('.chart').addClass(groupBy).addClass(filterBy);
chart(column,filterBy,groupBy);

styles.css

body {
	font-size: 16px;
	color: #4a4a4a;
	background-color: #fcfcfc;
	overflow-x: hidden;
}

/*CHART GENERAL*/
.chart-wrapper{
	margin-left:-.99%;
	width: 102%;
}

/*AXES*/
.axis path, .axis line {
  fill: none;
  stroke: #3a403d;
  stroke-width: 1px;
  shape-rendering: crispEdges;
}
.axis text {
  font-size: .625em;
}

/*LAYERS*/
.chart .layer {
	cursor: crosshair;
}

/*TIP*/
.tip {
  background-color: hsla(0,0%,100%,.9);
  padding: 10px;
  border-radius: 5px;
  font-size: 1em;
	width: 180px;
}
.tip .year {
  font-weight: 900;
  font-size: 1.25em;
  margin-bottom: 2px;
}
.tip .swatch {
  width: 11px;
  height: 11px;
  float: left;
  margin-top: 6px;
  margin-right: 4px;
}

/*LEGEND*/
.legend {
	position: absolute;
	top: 15px;
	left: 30px;
	background-color: hsla(0,0%,100%,.9);
	padding: 5px 10px 5px 10px;
	border-radius: 5px;
}
.legend .title {
	margin-bottom: 2px;
	font-weight: 900;
}
.legend .item {
	font-size: .75em;
}
.legend .swatch {
	width: 10px;
	height: 10px;
	margin-top: 3px;
	float: left;
	margin-right: 5px;
}

/* Extra Small Devices, Phones */
@media only screen and (max-width : 480px) {

	/*chart*/
	.chart-wrapper {
		margin-left:-15px;
		margin-right:-15px;
		width: auto;
	}
	.chart-title {
		font-size: 1.4em;
	}

	/*tip*/
	.tip {
		width: 100px;
	}
	.tip .year {
		font-size: 1em;
	}
	.tip .key, .tip .value {
		font-size: .75em;
	}
	.tip .swatch {
		width: 10px;
		height: 10px;
		margin-top: 3.5px;
	}
	.remove {
		display: none;
	}

	/*legend*/
	.legend {
		position: static;
		background-color: #fcfcfc;
	}
	.legend .title {
		display: none;
	}
	.legend .item {
		float:left;
		margin-right:10px;
	}
	.legend .swatch {
		margin-top: 3.5px;
	}

}