|
<!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> |