block by jeremycflin 73784ee974c071d84708b2f504c908e7

Radar Chart

Full Screen

An example of a radar chart visualising my housemates parkrun data (number of runs and best time for each park run route). Inspired by Nadieh Bremers Radar Charts - code mostly my own (other than the glow effect).

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.

forked from tlfrd‘s block: Radar Chart

index.html

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="d3-scale-radial.js"></script>
  <style>
    body { 
      margin: 0; position: fixed; top: 0; right: 0; bottom: 0; left: 0; 
      font-family: monospace;
    }
    
    .menu {
    	position: absolute;
      top: 15px;
      left: 15px;
    }
    
    text {
      text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff;
    }
    
    .x-tick {
      stroke: black;
      opacity: 0.5;
    }
    
    .x-tick-long {
      stroke: black;
      stroke-dasharray: 5, 5;
      opacity: 0.3;
    }
    
    .y-tick circle {
      fill: grey;
      font-size: 10px;
      opacity: 0.1;
    }
    
    .y-tick text {
      font-size: 10px;
      opacity: 0.5;
    }
    
    .label text {
      font-size: 10px;
    }
    
    .dot-run {
      fill: #B06600;
    }
    
    .runs {
      fill: #B06600;
      fill-opacity: 0.35;
      stroke: #B06600;
      stroke-width: 1px;
    }
    
    .hover-dot {
      opacity: 0;
    }
  </style>
</head>

<body>
  <div class="menu">
    Visualise
    <select class="visualise">
      <option value="runs">Runs</option>
      <option value="time">Best Time</option>
    </select>
  </div>
  <script>
    
    d3.select(".visualise")
    	.on("change", function() {
      	var attribute = d3.select(this).property("value");
      	mode = attribute;
      	change();
    	})
        
    var mode = "runs";
    
		var margin = {top: 100, right: 100, bottom: 100, left: 100};
    
    var width = 960 - margin.left - margin.right,
    		height = 500 - 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 center = svg.append("g")
    	.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
    
    center.on("click", function() {
      if (mode == "runs") {
        mode = "time";
        change();
      } else {
        mode = "runs";
        change();
      }
    })
    
    var radius = Math.min(width, height) / 2 + 10;
    var radiusTextSpacing = 50;
    
    var parseTime = d3.timeParse("%M:%S"),
        formatTime = d3.timeFormat("%M:%S");
    
    var fullCircle = 2 * Math.PI;
    
    var dotRadius = 2;
    
    var x = d3.scaleBand()
    	.range([0, 2 * Math.PI]);
    
    var y = d3.scaleRadial()
    	.range([0, radius]);
    
    var z = d3.scaleTime()
    	.range([radius, 0]);
    
    // A filter shamelessly stolen from Nadieh Bremer
    var filter = svg.append('defs').append('filter').attr('id','glow'),
      feGaussianBlur = filter.append('feGaussianBlur').attr('stdDeviation','7').attr('result','coloredBlur'),
      feMerge = filter.append('feMerge'),
      feMergeNode_1 = feMerge.append('feMergeNode').attr('in','coloredBlur'),
      feMergeNode_2 = feMerge.append('feMergeNode').attr('in','SourceGraphic');
    
    var areaRuns = d3.areaRadial()
    	.angle(function(d) { return x(d.event)})
    	.outerRadius(function(d) { return y(d.run)})
    	.curve(d3.curveCatmullRomClosed);
    
    var areaTime = d3.lineRadial()
    	.angle(function(d) { return x(d.event); })
    	.radius(function(d) { return z(d.best_time); })
    	.curve(d3.curveCatmullRomClosed);
    
    var runs, hoverCirclesRuns, labels;
    var yTick, yAxis;
    
    d3.csv("parkrun.csv", function(d) {
			d.best_time = parseTime(d.best_time.slice(-5))     
      d.best_gender_position = +d.best_gender_position;
      d.best_position_overall = +d.best_position_overall;
      d.run = +d.run;
      return d;
    }, function(error, data) {
      if (error) throw error;
      
      data = data.sort(function(a, b) {
        return a.event < b.event;
      });
      
      x.domain(data.map(function(d) {
        return d.event;
      }));
      
      y.domain([0, d3.max(data, function(d) {
        return d.run;
      })]);
      
      var slowestTime = "24:00";
      
      z.domain([d3.min(data, function(d) {
        return d.best_time;
      }), parseTime(slowestTime)]);
      
      var xAxis = center.append("g")
      	.attr("text-anchor", "middle");
      
      var xTick = xAxis.selectAll("g")
      	.data(data)
      .enter().append("g");
      
      xTick.append("line")
      	.attr("class", "x-tick")
      	.attr("y2", radius)
      	.attr("transform", function(d) {
          return "rotate(" + (x(d.event) / fullCircle * 360) + ")";
        });
      
      xTick.append("line")
      	.attr("class", "x-tick-long")
      	.attr("y1", radius)
      	.attr("y2", radius + 30)
      	.attr("transform", function(d) {
          return "rotate(" + (x(d.event) / fullCircle * 360) + ")";
        });
      
      yAxis = center.append("g")
      	.attr("text-anchor", "middle");
     	
      addAxis(0);
      
      labels = xTick.append("g")
      	.attr("class", "label");      
      
      labels.append("text")
      	.attr("y", radius + radiusTextSpacing)
      	.attr("x", function(d) {
        	return Math.cos(x(d.event) + Math.PI / 2) * (radius + radiusTextSpacing);
      	})
      	.attr("y", function(d) {
        	return Math.sin(x(d.event) + Math.PI / 2) * (radius + radiusTextSpacing);
      	})
      	.attr("dy", "0.35em")
      	.text(function(d) {
          return d.event;
        });
      
      runs = center.append("g");
      
      runs.append("path")
      	.datum(data)
      	.attr("class", "runs")
      	.attr("d", areaRuns)
      	.attr("transform", "rotate(180)")
      	.style("filter" , "url(#glow)")
        .on("mouseover", function() {
        	d3.select(".times").transition()
            .style("fill-opacity", 0.05);
      	})
      	.on("mouseout", function() {
        	d3.select(".times").transition()
            .style("fill-opacity", 0.35);
      	});
            
      runs.selectAll("circle")
      	.data(data)
      .enter().append("circle")
      	.attr("class", "dot-run")	
     		.attr("cy", function(d) {
          return y(d.run);
        })
      	.attr("r", dotRadius)
      	.attr("transform", function(d) {
          return "rotate(" + (x(d.event)) / fullCircle * 360 + ")";
        })
      	.style("filter" , "url(#glow)");
            
      hoverCirclesRuns = center.append("g");
      
      hoverCirclesRuns.selectAll("circle")
        .data(data)
      .enter().append("circle")
      	.attr("class", "hover-dot")	
     		.attr("cy", function(d) {
          return y(d.run);
        })
      	.attr("r", dotRadius * 3)
      	.attr("transform", function(d) {
          return "rotate(" + (x(d.event)) / fullCircle * 360 + ")";
        })
      	.on("mouseover", function(d) {
        	var multiplier = mode == "runs" ? y(d.run) : z(d.best_time);
        	var unit = d.run == 1 ? " run" : "runs";
        	var labelText = mode == "runs" ? d.run + unit : formatTime(d.best_time);
        
        	d3.select(this.parentNode).append("text")
            .attr("x", function() {
              return Math.cos(x(d.event) + Math.PI / 2) * multiplier;
            })
            .attr("y", function() {
              return Math.sin(x(d.event) + Math.PI / 2) * multiplier;
            })
          	.attr("dy", "-1em")
          	.attr("text-anchor", "middle")
          	.text(labelText);
      	})
      	.on("mouseout", function(d) {
        	d3.select(this.parentNode).select("text").remove();
      	});
      
  
    });
    
    function change() {
        runs.select("path")
          .transition()
          .duration(1000)
          .attr("d", function(d) {
          	return mode == "time" ? areaTime(d) : areaRuns(d);
        	})
          .style("fill", function() {
          	return mode == "time" ? "none" : "#B06600";
        	});
      
        runs.selectAll("circle")
          .transition()
          .duration(1000)
          .attr("cy", function(d) {
            return mode == "time" ? z(d.best_time) : y(d.run);
          })
          .style("fill", function() {
          	return mode == "time" ? "#bfbd00" : "#B06600";
        	});
        
        hoverCirclesRuns
          .selectAll("circle")
          .attr("cy", function(d) {
             return mode == "time" ? z(d.best_time) : y(d.run);
          });
        
        yTick.selectAll("circle")
          .transition()
          .duration(250)
        	.attr("r", 0);
        
        yTick.selectAll("text")
          .transition()
          .duration(250)
        	.attr("y", 0)
        	.on("end", function() {
        		yAxis.selectAll("g").remove();
          	addAxis(250);
        	})
    }
    
    function addAxis(time) {
        yTick = yAxis.selectAll("g")
          .data(function() {
          	return mode == "runs" ? y.ticks(5).slice(1) : z.ticks(5).slice(0, -1);
        	})
        .enter().append("g")
          .attr("class", "y-tick");

        yTick.append("circle")
          .transition()
          .duration(time * 3)
          .attr("r", function(d) {
          	return mode == "runs" ? y(d) : z(d);
          });

        yTick.append("text")
          .transition()
          .duration(time * 3)
          .attr("y", function(d) {
          	return mode == "runs" ? -y(d) : -z(d);
          })
          .attr("dy", "0.35em")
          .text(function(d) {
          	return mode == "runs" ? d : formatTime(d);
          });
    }

  </script>
</body>

d3-scale-radial.js

(function(global, factory) {
  typeof exports === "object" && typeof module !== "undefined" ? factory(exports, require("d3-scale")) :
  typeof define === "function" && define.amd ? define(["exports", "d3-scale"], factory) :
  (factory(global.d3 = global.d3 || {}, global.d3));
}(this, function(exports, d3Scale) {
  'use strict';

  function square(x) {
    return x * x;
  }

  function radial() {
    var linear = d3Scale.scaleLinear();

    function scale(x) {
      return Math.sqrt(linear(x));
    }

    scale.domain = function(_) {
      return arguments.length ? (linear.domain(_), scale) : linear.domain();
    };

    scale.nice = function(count) {
      return (linear.nice(count), scale);
    };

    scale.range = function(_) {
      return arguments.length ? (linear.range(_.map(square)), scale) : linear.range().map(Math.sqrt);
    };

    scale.ticks = linear.ticks;
    scale.tickFormat = linear.tickFormat;

    return scale;
  }

  exports.scaleRadial = radial;

  Object.defineProperty(exports, '__esModule', {value: true});
}));

parkrun.csv

event,run,best_gender_position,best_position_overall,best_time
Aberdeen,85,4,4,00:19:00
Inverness,73,2,2,00:18:49
Fulham Palace,21,12,12,00:18:37
Wimbledon,20,9,10,00:19:09
Hereford,6,2,2,00:19:11
Hampstead Heath,6,10,10,00:20:22
Highbury Fields,5,10,10,00:19:12
Portobello,2,6,6,00:18:57
Bushy,1,61,61,00:19:49
Keswick,1,14,14,00:21:17
Clair,1,6,6,00:20:32
Ally Pally,1,9,9,00:20:53
Andover,1,5,5,00:22:20
Edinburgh,1,28,30,00:19:55
Burgess,1,6,6,00:18:48
Bedford,1,6,6,00:19:02
Hackney Marshes,1,10,10,00:19:07
Springburn,1,7,7,00:19:48
Yeovil Montacute,1,4,4,00:20:27
Wormwood Scrubs,1,5,5,00:20:07
Perth,1,7,7,00:20:39