Histogram of Boston's BAA Marathon and Half-marathon results from 2011-2017. Uses d3.compose. Data scraped from various sources and may not be reliable. Thanks to Tim Hall for code review.
xxxxxxxxxx
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="https://npmcdn.com/d3.compose@0.15.18/dist/d3.compose.css">
<style>
@import url(https://fonts.googleapis.com/css?family=Roboto:400,300,300italic,400italic,700,700italic,900,900italic);
body {
font-family: Roboto, Arial;
font-size: 12px;
}
.chart {
margin: 10px;
font-family: sans-serif;
}
.chart-compose {
border: none;
}
.title {
font-weight: 200;
font-size: 2em;
}
.subtitle {
font-weight: 200;
font-size: 1em;
}
.chart-axis {
font-size: 1em;
}
.chart-label-bg {
fill: white;
opacity: 1;
}
.chart-bars rect:hover {
opacity: 0.8;
cursor: pointer;
}
.women {
fill: #DD5252;
}
.women line,
path.women {
stroke: #DD5252;
}
.women .chart-label-text {
fill: #DD5252;
}
.men {
fill: #049ACA;
}
.men line,
path.men {
stroke: #049ACA;
}
.men .chart-label-text {
fill: #049ACA;
}
.fastest,
.average {
stroke-width: 1px;
stroke: black;
}
.average {
stroke-dasharray: 5 2;
}
.tooltip {
margin: 0;
padding: 5px;
font-size: 12px;
border: solid 1px #666;
background-color: #fff;
}
.tooltip:before {
border-left: solid transparent 5px;
border-right: solid transparent 5px;
border-top: solid #666 5px;
bottom: -4.5px;
content: " ";
height: 0;
left: 50%;
margin-left: -5px;
position: absolute;
width: 0;
}
.tooltip:after {
border-left: solid transparent 5px;
border-right: solid transparent 5px;
border-top: solid #fff 5px;
bottom: -3px;
content: " ";
height: 0;
left: 50%;
margin-left: -5px;
position: absolute;
width: 0;
}
select {
margin-right: 10px;
}
.range-text {
font-weight: bold;
margin-left: 5px;
}
.controls {
text-align: center;
}
.spin {
text-align: center;
margin-top: 100px;
}
</style>
<body>
<div id="chart" class="chart">
<div class="spin">Loading data...</div>
</div>
<div class="controls">
Dataset:
<select id="dataset">
<option selected value="marathon">Boston Marathon</option>
<option value="half_marathon">Boston Half-Marathon</option>
</select>
Year:
<select id="year">
<option value="2001">2001</option>
<option value="2002">2002</option>
<option value="2003">2003</option>
<option value="2004">2004</option>
<option value="2005">2005</option>
<option value="2006">2006</option>
<option value="2007">2007</option>
<option value="2008">2008</option>
<option value="2009">2009</option>
<option value="2010">2010</option>
<option value="2011">2011</option>
<option value="2012">2012</option>
<option value="2013">2013</option>
<option value="2014">2014</option>
<option value="2015">2015</option>
<option value="2016">2016</option>
<option selected value="2017">2017</option>
</select>
Gender:
<select id="sex">
<option selected value="women,men">Both</option>
<option value="men">Men</option>
<option value="women">Women</option>
</select>
Age group:
<select id="age_group">
<option selected value="0_120">All</option>
<option value="14_19">14-19</option>
<option value="20_24">20-24</option>
<option value="25_29">25-29</option>
<option value="30_34">30-34</option>
<option value="34_39">34-39</option>
<option value="40_44">40-44</option>
<option value="45_49">45-49</option>
<option value="50_54">50-54</option>
<option value="55_59">55-59</option>
<option value="60_64">60-64</option>
<option value="65_69">65-69</option>
<option value="70_74">70-74</option>
<option value="75_79">75-79</option>
<option value="80_120">80+</option>
</select>
Histogram interval:
<select id="interval">
<option value="0:01">1 min</option>
<option value="0:05">5 mins</option>
<option selected value="0:10">10 mins</option>
<option value="0:15">15 mins</option>
<option value="0:30">30 mins</option>
</select>
</div>
</body>
<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/d3/3.5.5/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3.chart/0.2.1/d3.chart.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.1/moment.min.js"></script>
<script src="https://npmcdn.com/d3.compose@0.15.18/dist/d3.compose-all.js"></script>
<script type="text/javascript">
var count_format = d3.format(',');
var average_format = d3.format('.2f');
var config = {
marathon: {
title_prefix: 'Boston Marathon results for',
year_range: d3.range(2001, 2018), // marathon data up to 2016
time_range: d3.range(2, 8, 0.5),
},
half_marathon: {
title_prefix: 'Boston Half-marathon results for',
year_range: d3.range(2011, 2018), // half-marathon data up to 2016
time_range: d3.range(1, 4, 0.5),
}
};
// ----------------
// Chart Definition
// ----------------
var chart = d3.select('#chart').chart('Compose', function(options) {
var data = options.data;
var choices = options.choices;
var stats = options.stats;
// Load domain from choices
// (last element is only used to create bins)
var domain = getRange(choices);
domain.pop();
var xScale = {
type: 'ordinal',
domain: domain,
padding: 0.3,
};
var xScaleLinear = d3.scale.linear().domain(d3.extent(choices.time_range));
var yScale = {
domain: [0, d3.max(_.pluck(data[0].values, 'total')) * 1.1],
};
// Gather the data for the fastest, average stats lines
var stats_label_data = [];
var stats_lines = [];
choices.gender_list.forEach(function(sex) {
['fastest', 'average'].forEach(function(stat_type) {
var x = stats[sex][stat_type];
stats_label_data.push({
x: x,
y: yScale.domain[1],
'class': sex + ' ' + stat_type,
label: formatHours(x),
});
stats_lines.push({
type: 'OverlayLine',
value: x,
orientation: 'vertical',
className: sex + ' ' + stat_type,
xScale: xScaleLinear,
yScale: yScale,
});
});
});
var charts = [
d3c.stackedBars('bars', {
data: data,
xScale: xScale,
yScale: yScale,
duration: 800,
}),
d3c.labels('label', {
data: stats_label_data,
xScale: xScaleLinear,
yScale: yScale,
alignment: 'middle',
offset: {x: 0, y: 0}
}),
];
var title = d3c.title({
text: choices.title,
'class': 'title',
margins: {bottom: 0, top: 0}
});
var subtitle = d3c.title({
text: 'Age group: ' + choices.age_group_text +
', Total runners: ' + count_format(stats.both.count) +
' (' + count_format(stats.men.count) +
' men / ' + count_format(stats.women.count) + ' women)',
'class': 'subtitle',
margins: {bottom: 20, top: 0}
});
var xAxis = d3c.axis('xAxis', {
scale: xScaleLinear,
tickValues: choices.time_range,
tickFormat: formatHours,
});
var yAxis = d3c.axis('yAxis', {
scale: yScale,
ticks: 5,
width: 150,
});
var yAxisTitle = d3c.axisTitle({text: 'Runners'});
var xAxisTitle = d3c.axisTitle({text: 'Time', margins: {top: 10, bottom:0}});
// Custom tooltip
var tooltip = {type: 'Tooltip'};
// Custom legend
var legend = d3c.insetLegend({
data: [
{type: 'Bars', key: 'women_count', text: 'Women', 'class': 'women'},
{type: 'Bars', key: 'men_count', text: 'Men', 'class': 'men'},
{type: 'Lines', key: 'fastest', text: 'Fastest', 'class': 'fastest'},
{type: 'Lines', key: 'average', text: 'Average', 'class': 'average'},
],
translation: {x: 10, y: 10, relative_to: 'right-top'},
stackDirection: 'vertical',
});
return [
title,
subtitle,
legend,
[yAxisTitle, yAxis, d3c.layered(charts)],
xAxis,
xAxisTitle,
tooltip,
stats_lines,
];
}).width(900)
.height(450)
.margins({bottom: 0, right: 20}); // avoid cropping of last x-axis label (3h30)
// ----------
// Initialize
// ----------
loadData(function(data) {
d3.select('.spin').remove();
d3.selectAll('select').on('change', draw);
draw();
function draw() {
updateSelects();
var choices = getChoices();
var options = filterData(data, choices);
chart.draw(options);
}
});
// --------------------------
// Reusable Tooltip Component
// --------------------------
var helpers = d3.compose.helpers;
var mixins = d3.compose.mixins;
var Component = d3c.Component;
var Overlay = d3c.Overlay;
Overlay.extend('Tooltip', {
initialize: function(options) {
Overlay.prototype.initialize.call(this, options);
this.p = this.base.append('p').attr('class', 'tooltip');
this.point = null;
this.on('attach', function() {
this.container.on('mouseenter:point', function(point) {
this.point = point;
this.render();
}.bind(this));
this.container.on('mouseleave:point', function() {
this.point = null;
this.hide();
}.bind(this));
});
},
render: function() {
// Note: for stacked bars, y is changed to stacked value, original value is stored as __original_y on d
var count_text = count_format(this.point.d.__original_y) + ' ' + this.point.series['class'] + ' (total ' + count_format(this.point.d.total) + ')';
var range_text = formatHours(this.point.d.range.min) + ' - ' + formatHours(this.point.d.range.max);
var spans = this.p.selectAll('span')
.data(['count', 'range']);
spans.enter().append('span')
.attr('class', function(d) { return d == 'count' ? 'count-text' : 'range-text'; });
spans
.text(function(d) { return d == 'count' ? count_text : range_text; });
this.show();
// Positioning mixes absolute position (width/height)
// and chart position (points)
// -> convert point to absolute, relative to chart
var tooltipHeight = 4.5;
var chart_position = this.container.chartPosition();
var absolute_point = this.getAbsolutePosition({
x: this.point.meta.x + chart_position.left,
y: this.point.meta.y + chart_position.top
});
this.position({
x: absolute_point.x - (this.width() / 2),
y: absolute_point.y - tooltipHeight - this.height()
});
}
});
// ------------------------------
// Reusable OverlayLine Component
// ------------------------------
var Mixed = helpers.mixin(Component, mixins.XY);
Mixed.extend('OverlayLine', {
initialize: function(options) {
Mixed.prototype.initialize.call(this, options);
var base = this.base.append('g').attr('class', 'chart-overlay');
var line = d3.svg.line().x(this.x).y(this.y);
this.layer('Overlay', base, {
dataBind: function() {
return this.selectAll('path')
.data([null]);
},
insert: function() {
var chart = this.chart();
return this.append('path')
.attr('class', chart.className());
},
events: {
merge: function() {
var chart = this.chart();
this.attr('d', function(d, i) {
var points = chart.points.call(this, d, i);
return line(points);
});
this.attr('class', chart.className());
},
}
});
},
// helpers.property creates get/set property
// that is set automatically from Compose options
value: helpers.property(),
orientation: helpers.property({
default_value: 'vertical',
validate: function(value) {
return value == 'vertical' || value == 'horizontal';
}
}),
className: helpers.property(),
// helpers.di binds chart to "di" functions
// so that "this" refers to the element (as expected)
points: helpers.di(function(chart) {
var points = [{}, {}];
if (chart.orientation() == 'horizontal') {
points[0].y = points[1].y = chart.value();
points[0].x = chart.xScale().domain()[0];
points[1].x = chart.xScale().domain()[1];
}
else {
points[0].x = points[1].x = chart.value();
points[0].y = chart.yScale().domain()[0];
points[1].y = chart.yScale().domain()[1];
}
return points;
}),
// Position overlay as chart layer,
// skipping standard component layout
skip_layout: true
}, {
layer_type: 'chart'
});
// -------------
// Data-handling
// -------------
function loadData(cb) {
var data = {};
var remaining = 2;
d3.csv('marathon.csv', function(csv_data) {
csv_data.forEach(function(d) {
d.age = +d.age;
d.year = +d.year;
d.sex = d.sex || d.gender; // handle differing dataset column names
d.hours = +d.mins/60;
});
data.marathon = csv_data;
if (!--remaining)
cb(data);
});
d3.csv('half_marathon.csv', function(csv_data) {
csv_data.forEach(function(d) {
d.age = +d.age;
d.year = +d.year;
d.sex = d.sex || d.gender; // handle differing dataset column names
d.hours = moment.duration(d.time).asHours();
});
data.half_marathon = csv_data;
if (!--remaining)
cb(data);
});
}
function filterData(data, choices) {
var values = data[choices.dataset];
var age_range = choices.age_range;
var both = values.filter(function(d) {
var year = (d.year == choices.year);
var age = (d.age >= age_range[0] && d.age <= age_range[1]);
return age && year;
});
var men = both.filter(function(d){ return d.sex == 'M'; });
var women = both.filter(function(d){ return d.sex == 'F'; });
var bins = buildHistogram(both, choices);
// Add range and breakdown to each bin
// (prev for right-most bin may contain elements that go beyond cutoff date, use max)
var prev = d3.max(both, function(d) { return d.hours; });
bins.reverse().forEach(function(bin) {
bin.range = {min: bin.x, max: prev};
prev = bin.x;
bin.total = bin.length;
bin.men = _.where(bin, {sex: 'M'}).length;
bin.women = _.where(bin, {sex: 'F'}).length;
});
// Map genders to series, selecting corresponding bins
var chart_series = choices.gender_list.map(function(sex) {
var series_values = bins.map(function(bin) {
var bin_info = _.pick(bin, 'x', 'range', 'total');
bin_info.y = bin[sex];
bin_info.values =_.sortBy(bin.map(function(d) {
return {
name: d.name,
age: d.age,
time: d.time,
hours: d.hours
};
}), 'hours');
return bin_info;
});
var series = {
key: sex,
name: sex,
values: series_values,
'class': sex
};
return series;
});
var options = {
data: chart_series,
stats: {
both: getStats(both),
men: getStats(men),
women: getStats(women),
},
choices: choices,
};
return options;
};
function buildHistogram(values, choices) {
// Reference: https://github.com/d3/d3-3.x-api-reference/blob/master/Histogram-Layout.md
var histogram = d3.layout.histogram()
.bins(getRange(choices))
.value(function(d) { return d.hours; });
return histogram(values);
}
function getStats(data) {
return {
count: data.length,
average: d3.sum(data, function(d) { return d.hours; }) / data.length,
fastest: d3.min(data, function(d) { return d.hours; }),
};
}
function getChoices() {
var choices = {};
// Obtain values and text from each of the select elements
d3.selectAll('select').each(function() {
var selected_option = this.selectedOptions[0];
if (selected_option) {
choices[this.id] = selected_option.value;
choices[this.id + '_text'] = selected_option.text;
}
});
var dataset_config = config[choices.dataset];
choices.gender_list = choices.sex.split(',');
choices.age_range = choices.age_group.split('_').map(Number);
choices.title = dataset_config.title_prefix + ' ' + choices.year;
choices.year_range = dataset_config.year_range;
choices.time_range = dataset_config.time_range;
return choices;
}
function updateSelects() {
var dataset = d3.select('#dataset').node().value;
var year_range = config[dataset].year_range;
// Adjust current value if outside of year range
var year = d3.select('#year');
var current_value = year.node().value;
if (current_value < year_range[0])
current_value = year_range[0];
var options = d3.select('#year').selectAll('option')
.data(year_range, value);
options.exit().remove();
options.enter().insert('option')
.attr('value', value)
.property('selected', function(d) {
return d == current_value;
})
.text(value);
function value(d) { return d; }
}
function getRoundedDuration(hours) {
// Takes hours as decimal, rounts to nearest minute, and returns duration
var duration = moment.duration(hours, 'hours');
var seconds = duration.seconds();
duration.seconds(0);
if (seconds >= 30)
duration.add(1, 'm');
return duration;
}
function formatHours(hours){
var duration = getRoundedDuration(hours);
return moment(duration._data).format('H:mm');
}
function getRange(choices) {
var hours = moment.duration(choices.interval).asHours();
var start_cutoff = _.first(choices.time_range);
var end_cutoff = _.last(choices.time_range);
var length = ((end_cutoff - start_cutoff)/hours) + 1;
return d3.range(length).map(function(d) {
return d3.round(start_cutoff + d*hours, 2);
});
}
</script>
https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js
https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js
https://cdnjs.cloudflare.com/ajax/libs/d3.chart/0.2.1/d3.chart.min.js
https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.1/moment.min.js
https://npmcdn.com/d3.compose@0.15.18/dist/d3.compose-all.js