I continued with dusting off my d3.js -skills. After revisiting how to draw responsive bar charts, I continued to address the second out of five d3.js coding challenges from freecodecamp’s course on data visualization . The purpose of this challenge is to create a responsive scatter plot of the 35 fastest bicycle riders to finish the climb up to Alpe d’Huez and indicate whether they were later confronted with doping allegations. You can get a first glimpse of the data here.
The data will be displayed in a two-dimensional graph with the year of the race on the horizontal and the finish time on the vertical axis. Upon hovering over a single marker the user is presented with additional information about the respective rider, the finish time the and whether there has been a doping allegation.
I again acknowledge that there are a lot of examples for response scatter plots on the web already. Still, I hope that this example serves as a good starting point for some people to create their own responsive charts. For this purpose I refrained from using too many unnecessary transitions and styling to keep the code short, concise and hopefully easy to comprehend.
I was actually able to reuse most of the code from my example on drawing responsive bar charts
and mainly had to swap the code responsible for drawing bars with code that draws some circles. Another subtle difference is that I used the most recent d3.js version6. The differences to older versions mostly minor, the only thing to look out for is that d3 now uses promises
for functions like d3.json()
so that one needs to change function calls that previously looked like d3.json("file" ,function(data) {...})
into d3.json("file").then(function(data) {...})
.
With all that being said lets look at the final chart:
The code is mainly split into six smaller parts that fulfill the following purposes:
- Load the data from an external source using the function
d3.json()
. - Append an
svg
object for the scatter plot with specified width and height to thebody
or adiv
in the webpage - Use appropriate scales to convert the domain of the data to the range of the
svg
. We used3.scaleLinear()
for transforming the years of the races that are given as integer numbers. The finish times are given in the formatMM:SS
and we thus used3.timeParse("%M:%S")
to convert those strings toDate
-objects that are understood byd3.scaleTime()
. - Draw and transform/translate horizontal and vertical axes to their correct positions in the
svg
. We used3.axisBottom()
andd3.axisLeft()
for the horizontal and vertical axis, respectively. - Draw the individual points of the chart and color them depending on whether there has been a doping allegation or not. Additionally define mouseover events that trigger the visibility of a tooltip that display further information on the data point in question. For this purpose we initialize a single
div
for the tooltip and change its visibility and position according to the position of the mouse. - Finalize the chart by adding appropriate labels as well as a legend and a title.
Note that we could have used some form of d3.scaleOrdinal()
to translate the information about doping allegations into a respective color for the data points. However, since we are only confronted with two cases (doping allegations or no doping allegations) I prefer to simply use an if-else condition (or a ternary operator) to set the corresponding colors of the markers.
The entire code that you need to reproduce the chart is given below or in this codepen app.
The inline-comments correspond to the different steps that I have outlined above. There are some additional css-styles that are mostly optional. The only important style is that for the tooltip which sets the initial visibility
to hidden
. Feel free to copy the implementation into a single file and run it locally on your own machine or use it as a template for your own scatter plot.
<!DOCTYPE html>
<meta charset="utf-8">
<html>
<head>
<script src="https://d3js.org/d3.v6.min.js" charset="utf-8"></script>
</head>
<style>
* {
font-family: sans-serif;
}
#tooltip {
visibility: hidden;
position: absolute;
opacity: 0.8;
padding: 10px;
vertical-align: middle;
border-radius: 5px;
background-color: #ecf0f1;
font-size: 14px;
}
.textbox {
font-size: 14px
}
#legend {
opacity: 0.2;
fill: #2c3e50;
}
#title {
text-anchor: middle;
font-size: 22px;
}
.label {
text-anchor: middle;
}
#svg{
background-color: white;
}
</style>
<body>
<div id=container align="center"></div>
<script type="text/javascript">
// Url to the input data
var url = "https://raw.githubusercontent.com/freeCodeCamp/ProjectReferenceData/master/cyclist-data.json"
// Colors to differentiate riders with and without doping allegations
var colors = ["#27ae60", "#8e44ad"]
// The attributes of the riders corresponding to the above colors
var legendKeys = ["No Doping Allegations", "Doping Allegations"]
// Create an invisible div for the tooltip
const tooltip = d3.select("body")
.append("div")
.attr("id", "tooltip")
.style("visibility", "hidden")
// 1. Load the data from external source
d3.json(url).then(function(data) {
// 2. Append svg-object for the bar chart to a div in your webpage
// (here we use a div with id=container)
var width = 700;
var height = 500;
var margin = {left: 90, top: 80, bottom: 50, right: 20};
var axisOffset = 10 // How for the axes are moved away from each other
const svg = d3.select("#container")
.append("svg")
.attr("id", "svg")
.attr("width", width)
.attr("height", height)
// 3. Define scales to translate domains of the data to the range of the svg
var xMin = d3.min(data, (d) => d["Year"]);
var xMax = d3.max(data, (d) => d["Year"]);
var parseTime = d3.timeParse("%M:%S");
var yMin = d3.min(data, (d) => parseTime(d["Time"]));
var yMax = d3.max(data, (d) => parseTime(d["Time"]));
var xScale = d3.scaleLinear()
.domain([xMin, xMax])
.range([margin.left + axisOffset, width- margin.right])
var yScale = d3.scaleTime()
.domain([yMax, yMin])
.range([height- margin.bottom - axisOffset, margin.top])
// 4. Draw and transform/translate horizontal and vertical axes
var xAxis = d3.axisBottom().scale(xScale).tickFormat(d3.format("d"))
var yAxis = d3.axisLeft().scale(yScale).tickFormat(d3.timeFormat("%M:%S"))
svg.append("g")
.attr("transform", "translate(0, "+ (height - margin.bottom) + ")")
.attr("id", "x-axis")
.call(xAxis)
svg.append("g")
.attr("transform", "translate("+ (margin.left)+", 0)")
.attr("id", "y-axis")
.call(yAxis)
// 5. Draw individual scatter points and define mouse events for the tooltip
svg.selectAll("scatterPoints")
.data(data)
.enter()
.append("circle")
.attr("cx", (d) => xScale(d["Year"]))
.attr("cy", (d) => yScale(parseTime(d["Time"])))
.attr("r", 5)
.attr("fill", (d) => (d["Doping"] == "") ? colors[0] : colors[1])
.attr("class", "dot")
.attr("data-xvalue", (d) => d["Year"])
.attr("data-yvalue", (d) => parseTime(d["Time"]))
.on("mouseover", function(d){
info = d["originalTarget"]["__data__"]
tooltip.style("visibility", "visible")
.style("left", event.pageX+10+"px")
.style("top", event.pageY-80+"px")
.attr("data-year", info["Year"])
.html(info["Name"]+" ("+info["Year"]+") <br> Time: "+info["Time"]+"<br><br>"+info["Doping"])
})
.on("mousemove", function(){
tooltip.style("left", event.pageX+10+"px")
})
.on("mouseout", function(){
tooltip.style("visibility", "hidden")
})
// 6. Finalize chart by adding title, axes labels and legend
svg.append("text")
.attr("x", margin.left + (width - margin.left - margin.right) / 2)
.attr("y", height - margin.bottom / 5)
.attr("class", "label")
.text("Year");
svg.append("text")
.attr("y", margin.left/4)
.attr("x", -height/2)
.attr("transform", "rotate(-90)")
.attr("class", "label")
.text("Time to finish");
svg.append("text")
.attr("x", margin.left + (width - margin.left - margin.right) / 2)
.attr("y", margin.top / 2.6)
.attr("id", "title")
.text("Doping in professional bike racing");
svg.append("text")
.attr("x", margin.left + (width - margin.left - margin.right) / 2)
.attr("y", margin.top / 1.4)
.text("35 fastest times to finish Alpe d'Huez")
.style("font-size", "16px")
.style("text-anchor", "middle")
svg.selectAll("legendSymbols")
.data(legendKeys)
.enter()
.append("circle")
.attr("cx", width - margin.right - 200)
.attr("cy", (d, i) => 150 + i * 25)
.attr("r", 5)
.attr("fill", (d, i) => colors[i])
svg.selectAll("legendTexts")
.data(legendKeys)
.enter()
.append("text")
.text((d) => d)
.attr("x", width - margin.right - 200 + 15)
.attr("y", (d, i) => 150 + i * 25 + 5)
.attr("class", "textbox")
const legend = svg.append("rect")
.attr("x", width - margin.right - 200 - 15)
.attr("y", 150-5-10)
.attr("rx", 5)
.attr("ry", 5)
.attr("width", 195)
.attr("height", 55)
.attr("id", "legend")
})
</script>
</body>
</html>