Skip to content

Instantly share code, notes, and snippets.

@brendansudol
Last active October 28, 2017 16:49
Show Gist options
  • Save brendansudol/f62d21d8118748158e26 to your computer and use it in GitHub Desktop.
Save brendansudol/f62d21d8118748158e26 to your computer and use it in GitHub Desktop.
(function() {
// initialize goal diff slider
var g_diff_slider = $('#g-diff-input');
g_diff_slider.noUiSlider({
start: 0,
step: 1,
range: {
min: -3,
max: 3
}
});
g_diff_slider.noUiSlider_pips({
mode: 'steps',
density: 12
});
// select grouped radio button (since i couldn't seem to do it in markdown)
d3.select('input[value="Home"]').property('checked', true);
var formatNum = d3.format(",.0f"),
formatPercent = d3.format(".0%"),
formatPercentOne = d3.format(".1%");
var minute_selected = 45;
var margin = {
top: 20,
right: 80,
bottom: 40,
left: 50
},
width = 700 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
// Add path interpolator to d3 for nicer
// transitioning for different sized datasets
// thanks to: http://stackoverflow.com/a/14330503
d3.interpolators.push(function(a, b) {
var isPath, isArea, interpolator, ac, bc, an, bn, d;
// Create a new array of a given length and fill it with the given value
function fill(value, length) {
return d3.range(length)
.map(function() {
return value;
});
}
// Extract an array of coordinates from the path string
function extractCoordinates(path) {
return path.substr(1, path.length - (isArea ? 2 : 1)).split('L');
}
// Create a path from an array of coordinates
function makePath(coordinates) {
return 'M' + coordinates.join('L') + (isArea ? 'Z' : '');
}
// Buffer the smaller path with coordinates at the same position
function bufferPath(p1, p2) {
var d = p2.length - p1.length;
if (isArea) {
return fill(p1[0], d / 2).concat(p1, fill(p1[p1.length - 1], d / 2));
} else {
return fill(p1[0], d).concat(p1);
}
}
isPath = /M-?\d*\.?\d*,-?\d*\.?\d*(L-?\d*\.?\d*,-?\d*\.?\d*)*Z?/;
if (isPath.test(a) && isPath.test(b)) {
isArea = a[a.length - 1] === 'Z';
ac = extractCoordinates(a);
bc = extractCoordinates(b);
an = ac.length;
bn = bc.length;
if (an > bn) {
bc = bufferPath(bc, ac);
}
if (bn > an) {
ac = bufferPath(ac, bc);
}
// Create an interpolater with the buffered paths (if both paths are of the same length,
// the function will end up being the default string interpolator)
interpolator = d3.interpolateString(bn > an ? makePath(ac) : a, an > bn ? makePath(bc) : b);
// If the ending value changed, make sure the final interpolated value is correct
return bn > an ? interpolator : function(t) {
return t === 1 ? b : interpolator(t);
};
}
});
var x = d3.scale.linear()
.domain([0, 95])
.range([0, width]);
var y = d3.scale.linear()
.domain([0, 1])
.range([height, 0]);
var color = d3.scale.ordinal()
.range(['#3498db', '#e74c3c', '#2ecc71']);
var xAxis = d3.svg.axis()
.scale(x)
.orient("bottom");
var yAxis = d3.svg.axis()
.scale(y)
.tickSize(-width)
.orient("left")
.tickFormat(formatPercent);
var line = d3.svg.line()
.x(function(d) {
return x(d.time);
})
.y(function(d) {
return y(d.prob);
});
var bisectX = d3.bisector(function(d) {
return d.time;
}).left;
var svg = d3.select("#outcome-prob-viz").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(responsivefy)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
d3.csv("/public/data/soccer-outcome-probs.csv", function(error, data) {
// get input values and filter to appropriate data
var inputs = get_inputs(),
data_selected = filter_data(inputs.g_diff, inputs.side);
// add axes
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis)
.append("text")
.attr("x", width)
.attr("y", 30)
.style("text-anchor", "end")
.text("Minute");
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
.append("text")
.attr("transform", "rotate(-90)")
.attr("y", -42)
.attr("dy", ".71em")
.style("text-anchor", "end")
.text("Probability");
// add initial prob lines
var outcome = svg.selectAll(".outcome")
.data(data_selected)
.enter().append("g")
.attr("class", "outcome");
outcome.append("path")
.attr("class", "line")
.attr("d", function(d) {
return line(d.values);
})
.style("stroke", function(d) {
return color(d.result);
});
// for hover functionality...
var focus = svg.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + x(minute_selected) + ",0)");
focus.append('line')
.attr('y2', height);
focus.append("text")
.attr("y", -5)
.style("text-anchor", "middle")
.text(minute_selected);
svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height)
.on("mousemove", mousemove);
// show win/lose/draw stats for a particular minute
show_stats(minute_selected);
// update chart if inputs changed
d3.selectAll("input").on("change", change);
g_diff_slider.on({ change: change });
// get values for goal-diff and side inputs
function get_inputs() {
var side_input = d3.select('input[name="side"]:checked');
return {
g_diff: parseInt(g_diff_slider.val()),
side: side_input.property('value'),
};
}
// filter & format data to win/lose/draw prob series
// based on goal_diff and home/away side
function filter_data(g_diff, side) {
var MIN_GAMES = 5;
var outcomes = ['home', 'away', 'draw'],
g_diff_adj = get_g_diff_adj(g_diff, side);
var data_filtered = outcomes.map(function(outcome) {
var result = get_result(outcome, side),
prob_col = make_col(g_diff_adj, outcome),
ct_col = make_col(g_diff_adj, 'tot_games');
values = [];
data.forEach(function(d) {
if (+d[ct_col] > MIN_GAMES) {
values.push({
time: d.time,
prob: +d[prob_col],
ct: +d[ct_col]
});
}
});
return {
outcome: outcome,
result: result,
values: values
};
});
data_filtered = _.sortBy(data_filtered, function(d) {
return d.result;
});
return data_filtered;
}
// as is, goal_diff is relative to home team
// this normalizes goal_diff depending on chosen side
function get_g_diff_adj(g_diff, side) {
return side == 'Home' ? g_diff : g_diff * -1;
}
// this transforms outcomes from home/away/draw
// to win/lose/draw depending on chosen side
function get_result(outcome, side) {
var r = outcome;
if (outcome == 'home') {
r = side == 'Home' ? 'win' : 'lose';
} else if (outcome == 'away') {
r = side == 'Home' ? 'lose' : 'win';
}
return r;
}
// format column name so that it
// matches output from pandas dataset
function make_col(diff, name) {
return "(" + diff + ".0, '" + name + "')";
}
// show minute level win/lose/draw probs
function show_stats(minute) {
var probs = get_minute_stats(minute),
results = ['win', 'lose', 'draw'];
results.forEach(function(r) {
var output = d3.select('#' + r + '-rate');
output.text(function() {
return probs[r] !== undefined ? formatPercentOne(probs[r]) : '-';
});
});
ct_output = d3.select("#game-ct");
ct_output.text(function() {
return (probs.ct && probs.ct > 0) ? formatNum(probs.ct) : '-';
});
}
// fetch minute level stats based on inputs and minute
function get_minute_stats(minute) {
var probs = {};
data_selected.forEach(function(d) {
var output = d3.select('#' + d.result + '-rate');
var d_min = d.values.filter(function(v) {
return v.time == minute;
});
if (d_min.length > 0) {
probs[d.result] = d_min[0].prob;
probs.ct = d_min[0].ct;
}
});
return probs;
}
// sub in new prob lines when inputs change
function change() {
inputs = get_inputs();
data_selected = filter_data(inputs.g_diff, inputs.side);
// update x domain (minutes)
x.domain([
d3.min(data_selected, function(d) {
return d3.min(d.values, function(v) {
return +v.time;
});
}),
95
]);
var adj_min_selected = minute_selected < x.domain()[0] ?
-100 : minute_selected;
show_stats(adj_min_selected);
update_min_line(adj_min_selected);
svg.transition()
.duration(750)
.select(".x.axis")
.call(xAxis);
var outcome = svg.selectAll(".outcome")
.data(data_selected);
outcome.select(".line")
.transition().duration(750)
.attr("d", function(d) {
return line(d.values);
});
}
// update minute line and stat line on hover
function mousemove() {
var x0 = x.invert(d3.mouse(this)[0]),
i = bisectX(data, x0, 1),
d0 = data[i - 1],
d1 = data[i],
d = x0 - d0.time > d1.time - x0 ? d1 : d0;
minute_selected = d.time;
show_stats(minute_selected);
update_min_line(minute_selected);
}
function update_min_line(minute) {
focus.attr("transform", "translate(" + x(minute) + ",0)");
focus.select("text").text(minute);
}
});
// for making viz responsive
function responsivefy(svg) {
var container = d3.select(svg.node().parentNode),
width = parseInt(svg.style("width")),
height = parseInt(svg.style("height")),
aspect = width / height;
svg.attr("viewBox", "0 0 " + width + " " + height)
.attr("perserveAspectRatio", "xMinYMid")
.call(resize);
d3.select(window).on("resize." + container.attr("id"), resize);
function resize() {
var targetWidth = parseInt(container.style("width"));
svg.attr("width", targetWidth);
svg.attr("height", Math.round(targetWidth / aspect));
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment