block by jeremycflin 90d87ef55f9f940e016d3a6a94a41b5d

Slopegraph

Full Screen

An example of a Slopegraph. Uses constraint relaxing to programmatically reposition labels to stop them from overlapping. Uses a voronoi to make line selection easier (this still isn’t ideal and some tweaking may be nessecary).

This is part of a series of visualisations called My Visual Vocabulary which aims to recreate every visualisation in the FT’s Visual Vocabulary from scratch using D3.

TODO:

forked from tlfrd‘s block: Slopegraph

index.html

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <link href="https://fonts.googleapis.com/css?family=Open+Sans:400, 700" rel="stylesheet">
  <style>
    body { margin:0;position:fixed;top:0;right:0;bottom:0;left:0; }
    
    body {
      font-family: 'Open Sans', sans-serif;
    }
    
    .title {
      font-size: 18px;
      font-weight: 700;
    }
    
    .slope-line {
      stroke: #333;
      stroke-width: 2px;
      stroke-linecap: round;
    }
    
    .slope-label-left, .slope-label-right {
      font-size: 16px;
      cursor: default;
      font-weight: 400;
    }
    
    .label-figure {
      font-weight: 700;
		}
    
    .border-lines {
      stroke: #999;
      stroke-width: 1px;
    }
    
    .voronoi path {
      fill: none;
      pointer-events: all;
    }
    
    circle {
      fill: white;
      stroke: black;
      stroke-width: 2px;
    }
  </style>
</head>

<body>
  <script>
    var margin = {top: 100, right: 275, bottom: 40, left: 275};
   
    var width = 960 - margin.left - margin.right,
    		height = 760 - margin.top - margin.bottom;
        
    var svg = d3.select("body").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 + ")");
    
    var url = "https://raw.githubusercontent.com/tlfrd/pay-ratios/master/data/payratio.json";
    
    var y1 = d3.scaleLinear()
    	.range([height, 0]);
    
    var config = {
      xOffset: 0,
      yOffset: 0,
      width: width,
      height: height,
      labelPositioning: {
        alpha: 0.5,
        spacing: 18
      },
      leftTitle: "2013",
      rightTitle: "2016",
      labelGroupOffset: 5,
      labelKeyOffset: 50,
      radius: 6,
      // Reduce this to turn on detail-on-hover version
      unfocusOpacity: 0.3
    }
    
    function drawSlopeGraph(cfg, data, yScale, leftYAccessor, rightYAccessor) {
      var slopeGraph = svg.append("g")
      	.attr("class", "slope-graph")
      	.attr("transform", "translate(" + [cfg.xOffset, cfg.yOffset] + ")");     
    }
    
    d3.json(url, function(error, data) {
      if (error) return error;
      
      // Combine ratios into a single array
      var ratios = [];
      data.pay_ratios_2012_13.forEach(function(d) {
        d.year = "2012-2013";
        ratios.push(d);
      });
      data.pay_ratios_2015_16.forEach(function(d) {
        d.year = "2015-2016";
        ratios.push(d);
      });
                  
      // Nest by university
      var nestedByName = d3.nest()
      	.key(function(d) { return d.name })
      	.entries(ratios);
      
      // Filter out those that only have data for a single year
      nestedByName = nestedByName.filter(function(d) {
        return d.values.length > 1;
      });
      
      var y1Min = d3.min(nestedByName, function(d) {        
        var ratio1 = d.values[0].max / d.values[0].min;
        var ratio2 = d.values[1].max / d.values[1].min;
        
        return Math.min(ratio1, ratio2);
      });
      
      var y1Max = d3.max(nestedByName, function(d) {        
        var ratio1 = d.values[0].max / d.values[0].min;
        var ratio2 = d.values[1].max / d.values[1].min;
        
        return Math.max(ratio1, ratio2);
      });
         
      // Calculate y domain for ratios
      y1.domain([y1Min, y1Max]);
      
      var yScale = y1;
            
      var voronoi = d3.voronoi()
        .x(d => d.year == "2012-2013" ? 0 : width)
        .y(d => yScale(d.max / d.min))
        .extent([[-margin.left, -margin.top], [width + margin.right, height + margin.bottom]]);
      
      var borderLines = svg.append("g")
      	.attr("class", "border-lines")
      borderLines.append("line")
      	.attr("x1", 0).attr("y1", 0)
      	.attr("x2", 0).attr("y2", config.height);
      borderLines.append("line")
      	.attr("x1", width).attr("y1", 0)
      	.attr("x2", width).attr("y2", config.height);
      
      var slopeGroups = svg.append("g")
      	.selectAll("g")
      	.data(nestedByName)
      	.enter().append("g")
      		.attr("class", "slope-group")
        	.attr("id", function(d, i) {
            d.id = "group" + i;
            d.values[0].group = this;
            d.values[1].group = this;
          });
      
      var slopeLines = slopeGroups.append("line")
        .attr("class", "slope-line")
        .attr("x1", 0)
        .attr("y1", function(d) {
          return y1(d.values[0].max / d.values[0].min);
        })
        .attr("x2", config.width)
        .attr("y2", function(d) {
          return y1(d.values[1].max / d.values[1].min);
        });
      
      var leftSlopeCircle = slopeGroups.append("circle")
      	.attr("r", config.radius)
      	.attr("cy", d => y1(d.values[0].max / d.values[0].min));
      
      var leftSlopeLabels = slopeGroups.append("g")
      	.attr("class", "slope-label-left")
      	.each(function(d) {
          d.xLeftPosition = -config.labelGroupOffset;
          d.yLeftPosition = y1(d.values[0].max / d.values[0].min);
        });
      
      leftSlopeLabels.append("text")
      	.attr("class", "label-figure")
      	.attr("x", d => d.xLeftPosition)
				.attr("y", d => d.yLeftPosition)
        .attr("dx", -10)
        .attr("dy", 3)
        .attr("text-anchor", "end")
        .text(d => (d.values[0].max / d.values[0].min).toPrecision(3));
      
      leftSlopeLabels.append("text")
      	.attr("x", d => d.xLeftPosition)
				.attr("y", d => d.yLeftPosition)
        .attr("dx", -config.labelKeyOffset)
        .attr("dy", 3)
        .attr("text-anchor", "end")
        .text(d => d.key);
      
      var rightSlopeCircle = slopeGroups.append("circle")
      	.attr("r", config.radius)
      	.attr("cx", config.width)
      	.attr("cy", d => y1(d.values[1].max / d.values[1].min));
      
      var rightSlopeLabels = slopeGroups.append("g")
      	.attr("class", "slope-label-right")
      	.each(function(d) {
          d.xRightPosition = width + config.labelGroupOffset;
          d.yRightPosition = y1(d.values[1].max / d.values[1].min);
        });
      
      rightSlopeLabels.append("text")
      	.attr("class", "label-figure")
				.attr("x", d => d.xRightPosition)
				.attr("y", d => d.yRightPosition)
        .attr("dx", 10)
        .attr("dy", 3)
        .attr("text-anchor", "start")
        .text(d => (d.values[1].max / d.values[1].min).toPrecision(3));
      
     	rightSlopeLabels.append("text")
				.attr("x", d => d.xRightPosition)
				.attr("y", d => d.yRightPosition)
        .attr("dx", config.labelKeyOffset)
        .attr("dy", 3)
        .attr("text-anchor", "start")
        .text(d => d.key);
      
      var titles = svg.append("g")
      	.attr("class", "title");
      
      titles.append("text")
      	.attr("text-anchor", "end")
      	.attr("dx", -10)
      	.attr("dy", -margin.top / 2)
      	.text(config.leftTitle);
      
      titles.append("text")
      	.attr("x", config.width)
      	.attr("dx", 10)
      	.attr("dy", -margin.top / 2)
      	.text(config.rightTitle);
      
      relax(leftSlopeLabels, "yLeftPosition");
      leftSlopeLabels.selectAll("text")
      	.attr("y", d => d.yLeftPosition);
      
      relax(rightSlopeLabels, "yRightPosition");
      rightSlopeLabels.selectAll("text")
      	.attr("y", d => d.yRightPosition);
      
      d3.selectAll(".slope-group")
      	.attr("opacity", config.unfocusOpacity);
      
      var voronoiGroup = svg.append("g")
      	.attr("class", "voronoi");
      
      voronoiGroup.selectAll("path")
      	.data(voronoi.polygons(d3.merge(nestedByName.map(d => d.values))))
      	.enter().append("path")
      		.attr("d", function(d) { return d ? "M" + d.join("L") + "Z" : null; })
      		.on("mouseover", mouseover)
      		.on("mouseout", mouseout);
    });
    
    function mouseover(d) {
      d3.select(d.data.group).attr("opacity", 1);
    }
    
    function mouseout(d) {
      d3.selectAll(".slope-group")
      	.attr("opacity", config.unfocusOpacity);
    }
    
    // Function to reposition an array selection of labels (in the y-axis)
    function relax(labels, position) {
      again = false;
      labels.each(function (d, i) {
        a = this;
        da = d3.select(a).datum();
        y1 = da[position];
        labels.each(function (d, j) {
          b = this;
          if (a == b) return;
          db = d3.select(b).datum();
          y2 = db[position];
          deltaY = y1 - y2;

          if (Math.abs(deltaY) > config.labelPositioning.spacing) return;

          again = true;
          sign = deltaY > 0 ? 1 : -1;
          adjust = sign * config.labelPositioning.alpha;
          da[position] = +y1 + adjust;
          db[position] = +y2 - adjust;

          if (again) {
            relax(labels, position);
          }
        })
      })
    }
      

  </script>
</body>