Skip to content

Instantly share code, notes, and snippets.

@tlfrd
Last active January 1, 2020 08:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tlfrd/e1ddc3d0289215224a69405f5e538f51 to your computer and use it in GitHub Desktop.
Save tlfrd/e1ddc3d0289215224a69405f5e538f51 to your computer and use it in GitHub Desktop.
Connected Dot Plot
license: mit

A connected dot plot showing the pay gap between the lowest, median, and highest paid employees at universities. Hover over dots to reveal the data. Click the screen to change between landscape and portrait versions.

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<script src="https://d3js.org/d3.v4.min.js"></script>
<style>
body {
font-family: sans-serif;
font-size: 12px;
margin: 0;
}
.checkbox {
position: absolute;
top: 15px;
left: 15px;
}
.dropdown {
position: absolute;
top: 15px;
right: 15px;
}
text {
font-size: 12px;
}
.title {
font-weight: bold;
}
.grid-line {
stroke: black;
opacity: 0.2;
}
.rect-label {
stroke: black;
}
.selection-line {
stroke: black;
stroke-dasharray: 5,5;
}
.hover-label {
background-color: blue;
}
.lollipop-line {
stroke: black;
stroke-width: 1px;
}
circle {
stroke-width: 1px;
stroke: black;
}
circle.lollipop-start {
fill: #00c100;
}
.lollipop-end {
fill: #d700d7;
}
.lollipop-median {
fill: white;
}
div.tooltip {
position: absolute;
text-align: center;
padding: 5px;
font: 12px sans-serif;
background: white;
border: 1px black solid;
border-radius: 4px;
pointer-events: none;
}
</style>
</head>
<body>
<div class="checkbox">
Show Line On Hover
<input type="checkbox" class="hover-line">
</div>
<div class="dropdown">
Sort By
<select class="sort-by">
<option value="name">Name</option>
<option value="max">Highest Salary</option>
<option value="median">Median Salary</option>
<option value="min">Lowest Salary</option>
</select>
</div>
<script>
var hoverLine = false;
var landscape = true;
d3.select(".hover-line")
.on("click", function() {
hoverLine = d3.select(this).property("checked");
});
d3.select(".sort-by")
.on("change", function() {
var attribute = d3.select(this).property("value");
sortLollipops(attribute, 1);
});
var margin = {top: 120, right: 100, bottom: 50, left: 150};
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 + ")");
function sortBy(data, attribute, order) {
data.sort(function(a, b) {
if(a[attribute] < b[attribute]) return -1 * order;
if(a[attribute] > b[attribute]) return 1 * order;
return 0;
});
}
// need to rewrite so start, min, lowest are the same
var classToPos = {
"lollipop-start": "min",
"lollipop-median": "median",
"lollipop-end": "max"
}
var legendLabels = [
{label: "Lowest", class: "lollipop-start"},
{label: "Median", class: "lollipop-median"},
{label: "Highest", class: "lollipop-end"}
];
var legendX = 300,
legendY = -50,
spaceBetween = 70,
titleOffset = -120;
// code for positioning legend
var legend = svg.append("g")
.attr("transform", "translate(" + [legendX, legendY] + ")");
legend.append("g")
.attr("class", "title")
.append("text")
.attr("x", titleOffset)
.text("Yearly Salary (£)")
// Try using Susie Lu's d3-legend
// add circles
legend.selectAll("circle")
.data(legendLabels)
.enter().append("circle")
.attr("cx", function(d, i) {
return spaceBetween * i;
})
.attr("cy", -4)
.attr("r", 5)
.attr("class", function(d) { return d.class });
// add labels
legend.append("g")
.selectAll("text")
.data(legendLabels)
.enter().append("text")
.attr("x", function(d, i) {
return spaceBetween * i + 10;
})
.text(function(d) { return d.label });
// Append div
var div = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
var posToColour = {
min: "#00c100",
median: "white",
max: "#d700d7"
};
var y = d3.scaleBand()
.range([0, height])
.paddingInner(0.5)
.paddingOuter(0.7);
var x = d3.scaleLinear()
.range([0, width]);
var lineGenerator = d3.line();
var axisLinePath = function(d) {
return lineGenerator([[x(d) + 0.5, 0], [x(d) + 0.5, height]]);
};
var lollipopLinePath = function(d) {
return lineGenerator([[x(d.min), 0], [x(d.max), 0]]);
};
var selectionLine,
lollipops,
yAxis,
yAxisGroup,
xAxis,
xAxisGroup,
axisLines,
lollipopsGroup;
var startCircles,
medianCircles,
endCircles;
// Make global for now
var payRatios;
var url = "https://raw.githubusercontent.com/tlfrd/pay-ratios/master/data/payratio.json";
d3.json(url, function(error, data) {
if (error) throw error;
payRatios = data.pay_ratios_2015_16;
// use -1 to flip ordering
sortBy(payRatios, "name", 1);
y.domain(payRatios.map(function(d) { return d.name }));
x.domain([0, d3.max(payRatios, function(d) { return d.max })]);
x.nice();
yAxis = d3.axisLeft().scale(y)
.tickSize(0);
xAxis = d3.axisTop().scale(x)
.tickFormat(function(d,i) {
if (i == 0) {
return "£0"
} else {
return d3.format(".2s")(d);
}
});
yAxisGroup = svg.append("g")
.attr("class", "y-axis-group");
yAxisGroup.append("g")
.attr("class", "y-axis")
.attr("transform", "translate(-20, 0)")
.call(yAxis)
.select(".domain")
.attr("opacity", 0);
xAxisGroup = svg.append("g")
.attr("class", "x-axis-group");
xAxisGroup.append("g")
.attr("class", "x-axis")
.call(xAxis);
axisLines = svg.append("g")
.attr("class", "grid-lines");
axisLines.selectAll("path")
.data(x.ticks())
.enter().append("path")
.attr("class", "grid-line")
.attr("d", axisLinePath);
selectionLine = xAxisGroup.append("g")
.attr("class", "interactive")
.append("path")
.attr("class", "selection-line")
.attr("d", function() {
return lineGenerator([[width / 2, 0],[width / 2, height]])
})
.attr("opacity", 0);
lollipopsGroup = svg.append("g").attr("class", "lollipops");
lollipops = lollipopsGroup.selectAll("g")
.data(payRatios)
.enter().append("g")
.attr("class", "lollipop")
.attr("transform", function(d) {
return "translate(0," + (y(d.name) + (y.bandwidth() / 2)) + ")";
})
lollipops.append("path")
.attr("class", "lollipop-line")
.attr("d", lollipopLinePath);
startCircles = lollipops.append("circle")
.attr("class", "lollipop-start")
.attr("r", 5)
.attr("cx", function(d) {
return x(d.min);
})
.on("mouseover", showLabel)
.on("mousemove", moveLabel)
.on("mouseout", hideLabel);
medianCircles = lollipops.append("circle")
.attr("class", "lollipop-median")
.attr("r", function(d) {
if (d.median === "N/A") {
return 0;
} else {
return 5;
}
})
.attr("cx", function(d) {
if (d.median === "N/A") {
return 0;
} else {
return x(d.median);
}
})
.on("mouseover", showLabel)
.on("mousemove", moveLabel)
.on("mouseout", hideLabel);
endCircles = lollipops.append("circle")
.attr("class", "lollipop-end")
.attr("r", 5)
.attr("cx", function(d) {
return x(d.max);
})
.on("mouseover", showLabel)
.on("mousemove", moveLabel)
.on("mouseout", hideLabel);
});
function showLabel() {
var selection = d3.select(this);
var pos = classToPos[selection.attr("class")];
var data = d3.select(this.parentNode).datum();
div.transition()
.duration(100)
.style("opacity", 0.9)
div.html("£" + (selection.datum()[pos]))
.style("left", (d3.event.pageX - 30) + "px")
.style("top", (d3.event.pageY - 40) + "px")
.style("background-color", posToColour[pos])
if (hoverLine) {
selectionLine
.attr("d", function() {
if (landscape) {
return lineGenerator([[x(data[pos]), 0], [x(data[pos]), height]]);
} else {
return lineGenerator([[0, x(data[pos])], [width, x(data[pos])]]);
}
})
.attr("opacity", 0.75);
}
}
function moveLabel() {
div.style("left", (d3.event.pageX - 30) + "px")
.style("top", (d3.event.pageY - 40) + "px")
}
function hideLabel() {
div.transition()
.duration(200)
.style("opacity", 0);
selectionLine
.attr("opacity", 0);
}
function sortLollipops(attribute, ordering) {
sortBy(payRatios, attribute, 1);
y.domain(payRatios.map(function(d) { return d.name })).copy();
if (landscape) {
lollipops
.transition()
.attr("transform", function(d) {
return "translate(" + [0, (y(d.name) + y.bandwidth() / 2)] + ")";
});
yAxisGroup.select(".y-axis")
.transition()
.call(yAxis);
} else {
lollipops
.transition()
.attr("transform", function(d) {
return "translate(" + [(y(d.name) + y.bandwidth() / 2), 0] + ")";
});
xAxisGroup.select(".x-axis")
.transition()
.call(yAxis);
}
}
d3.select("svg").on("click", switchBetweenPortraitAndLandscape);
function recalculateMargin(newMargin) {
width = 960 - newMargin.left - newMargin.right;
height = 500 - newMargin.top - newMargin.bottom;
d3.select("svg").select("g")
.attr("transform", "translate(" + newMargin.left + "," + newMargin.top + ")")
}
function switchBetweenPortraitAndLandscape() {
if (landscape) {
landscape = false;
recalculateMargin({top: 100, right: 100, bottom: 100, left: 100})
// Redraw xAxis
x.range([height, 0]);
xAxis = d3.axisLeft().scale(x)
.tickFormat(function(d, i) {
if (i == 0) {
return "£0"
} else {
return d3.format(".2s")(d);
}
});
yAxisGroup.select(".y-axis")
.call(xAxis)
.attr("transform", "translate(0, 0)")
.select(".domain")
.attr("opacity", 1);
// Redraw yAxis
y.range([0, width]);
yAxis = d3.axisBottom().scale(y)
.tickSize(0);
xAxisGroup.select(".x-axis")
.call(yAxis)
.attr("transform", "translate(0," + height + ")")
.select(".domain")
.attr("opacity", 0);
xAxisGroup.select(".x-axis").selectAll("text")
.attr("y", 9)
.attr("x", 9)
.attr("transform", "rotate(45)")
.style("text-anchor", "start");
// Redraw lollipops
lollipops.attr("transform", function(d) {
return "translate(" + [(y(d.name) + (y.bandwidth() / 2)), 0] + ")";
});
lollipops.select(".lollipop-line")
.attr("d", function(d) {
return lineGenerator([[0, x(d.min)], [0, x(d.max)]]);
})
lollipops.select(".lollipop-start")
.attr("cy", function(d) {
return x(d.min);
})
.attr("cx", 0);
lollipops.select(".lollipop-median")
.attr("cy", function(d) {
if (d.median === "N/A") {
return 0;
} else {
return x(d.median);
}
})
.attr("cx", 0);
lollipops.select(".lollipop-end")
.attr("cy", function(d) {
return x(d.max);
})
.attr("cx", 0);
// Redraw grid lines
axisLines.selectAll(".grid-line")
.attr("d", function(d) {
return lineGenerator([[0, x(d) + 0.5], [width, x(d) + 0.5]]);
});
// Move legend
legend.attr("transform", "translate(" + [legendX + 40, legendY] + ")");
} else {
landscape = true;
recalculateMargin(margin);
// Redraw xAxis
x.range([0, width]);
xAxis = d3.axisTop().scale(x)
.tickFormat(function(d, i) {
if (i == 0) {
return "£0"
} else {
return d3.format(".2s")(d);
}
});
xAxisGroup.select(".x-axis")
.call(xAxis)
.attr("transform", "translate(0,0)")
.select(".domain")
.attr("opacity", 1);
// Redraw yAxis
y.range([0, height]);
yAxis = d3.axisLeft().scale(y)
.tickSize(0);
yAxisGroup.select(".y-axis")
.call(yAxis)
.attr("transform", "translate(-20, 0)")
.select(".domain")
.attr("opacity", 0);
// Redraw lollipops
lollipops.attr("transform", function(d) {
return "translate(0," + (y(d.name) + (y.bandwidth() / 2)) + ")";
});
lollipops.select(".lollipop-line")
.attr("d", function(d) {
return lineGenerator([[x(d.min), 0], [x(d.max), 0]]);
})
lollipops.select(".lollipop-start")
.attr("cx", function(d) {
return x(d.min);
})
.attr("cy", 0);
lollipops.select(".lollipop-median")
.attr("cx", function(d) {
if (d.median === "N/A") {
return 0;
} else {
return x(d.median);
}
})
.attr("cy", 0);
lollipops.select(".lollipop-end")
.attr("cx", function(d) {
return x(d.max);
})
.attr("cy", 0);
// Redraw grid lines
axisLines.selectAll(".grid-line")
.attr("d", axisLinePath);
// Reposition legend
legend.attr("transform", "translate(" + [legendX, legendY] + ")");
}
}
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment