github twitter linkedin email
Responsive Scatter Plots with d3.js
May 6, 2021
6 minutes read

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:

  1. Load the data from an external source using the function d3.json().
  2. Append an svg object for the scatter plot with specified width and height to the body or a div in the webpage
  3. Use appropriate scales to convert the domain of the data to the range of the svg. We use d3.scaleLinear() for transforming the years of the races that are given as integer numbers. The finish times are given in the format MM:SS and we thus use d3.timeParse("%M:%S") to convert those strings to Date-objects that are understood by d3.scaleTime().
  4. Draw and transform/translate horizontal and vertical axes to their correct positions in the svg. We use d3.axisBottom() and d3.axisLeft() for the horizontal and vertical axis, respectively.
  5. 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.
  6. 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>

Back to posts