Skip to content

Instantly share code, notes, and snippets.

@tlfrd
Last active August 5, 2017 22:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tlfrd/dfee3b6061e50ad73d9df7a7a73b68e9 to your computer and use it in GitHub Desktop.
Save tlfrd/dfee3b6061e50ad73d9df7a7a73b68e9 to your computer and use it in GitHub Desktop.
Radar Chart
license: mit

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.

(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});
}));
<!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>
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment