Thursday, 14 November 2024

Web Tutorial: D3 Head-to-head Line Chart (Part 1/2)

As a Liverpool FC fan, the 2023/2024 season that ended back in May, was a pretty disappointing one. We started out in such promising fashion, only to stumble at several junctures, and effectively hand the league over to Arsenal and Manchester City.

However. in the midst of puzzling out how the top three teams - Manchester City, Arsenal and Liverpool - have performed against each other since the 2017/2018 season, I had the opportunity to put together a dataset, and then to visualize it using D3. This would ultimately lead to some astonishing insights.

Here we go with some beginning HTML!

You'll notice that we pre-emptively style all divs to have a red border, for temporary structural visibility. We also add a remote link to the D3 library. In the body tag, we add another script tag which we will populate later.
<!DOCTYPE html>
<html>
  <head>
    <title>Head-to-head Line Chart</title>

    <style>
      div { outline: 1px solid red; }
    </style>

    <script src="https://d3js.org/d3.v4.min.js"></script>
  </head>

  <body>
    <script>

    </script>
  </body>
</html>


Then we add a div, id teamsContainer, to the body, and inside it we add two divs. They will use the CSS styles left and right respectively. Right next to teamsContainer, we have another div, id chartContainer. Inside it, we have an SVG with an id of chart.
<!DOCTYPE html>
<html>
  <head>
    <title>Head-to-head Line Chart</title>

    <style>
      div { outline: 1px solid red; }
    </style>

    <script src="https://d3js.org/d3.v4.min.js"></script>
  </head>

  <body>
    <div id="teamsContainer">
      <div class="left">

      </div>
      <div class="right">

      </div>
    </div>


    <div id="chartContainer">
      <svg id="chart">

      </svg>        
    </div>


    <script>

    </script>
  </body>
</html>


Before we continue, let's style some stuff. teamsContainer will have a defined width and height, and we will use the margin property to center it. left and right take up roughly half of teamsContainer in width, and float left and right respectively.
<style>
div { outline: 1px solid red; }

#teamsContainer
{
  width: 800px;
  height: 200px;
  margin: 0 auto 0 auto;
}

.left
{
  width: 49%;
  float: left;
}

.right
{
  width: 49%;
  float: right;
}

</style>


Both chartContainer and chart will have the same defined height, width, and will be centered via the margin property.
<style>
div { outline: 1px solid red; }

#teamsContainer
{
  width: 800px;
  height: 200px;
  margin: 0 auto 0 auto;
}

.left
{
  width: 49%;
  float: left;
}

.right
{
  width: 49%;
  float: right;
}

#chartContainer, #chart
{
  width: 800px;
  height: 400px;
  margin: 0 auto 0 auto;
}

</style>


And then let's add drop-down lists! The CSS class will be ddlTeam.
<div class="left">
  <select class="ddlTeam">
  
  </select>

</div>
<div class="right">
  <select class="ddlTeam">
  
  </select>

</div>


In the CSS, we style ddlTeam accordingly. It's all aesthetics; feel free to go crazy.
.right
{
  width: 49%;
  float: right;
}

.ddlTeam
{
  width: 80%;
  height: 20%;
  text-align: center;
  margin: 0 auto 0 auto;
  border-radius: 3px;
  padding: 2px;
  display: block;
}


#chartContainer, #chart
{
  width: 800px;
  height: 400px;
  margin: 0 auto 0 auto;
}


Just taking shape here.


Now, let's do a bit of scripting! We need a h2h object in the script tag.
<script>
  let h2h =
  {

  };

</script>


In there, add the three properties - teams, data and currentData. teams will be an array, while data is an object. currentData is an empty array, and will stay empty by default until we need to populate it..
<script>
  let h2h =
  {
    teams:
    [
    
    ],
    data:
    {
    
    },
    currentData: []
  };
</script>


As it turns out, teams is an array of objects. The first object has name, color and logo properties. It's for Arsenal, so set color to a translucent scarlet. You'll notice that we have the filename for Arsenal's club crest in logo.
teams:
[
  { "name": "Arsenal", "color": "rgba(255, 0, 0, 0.5)", "logo": "arsenal.png"},
],


And here are the other teams! I have a deep red for Liverpool and a pale blue for Manchester City.
teams:
[
  { "name": "Arsenal", "color": "rgba(255, 0, 0, 0.5)", "logo": "arsenal.png"},
  { "name": "Manchester City", "color": "rgba(100, 200, 255, 0.5)", "logo": "manchestercity.png"},
  { "name": "Liverpool", "color": "rgba(150, 0, 0, 0.5)", "logo": "liverpool.png"}

],


These are the logos I use. I just save them in the same place, out of sheer laziness.

arsenal.png


liverpool.png


manchestercity.png


Now for data. We start off with the property 2018. It's an array.
data:
{
  2018:
  [

  ]

},


To be premise, it's an array of objects. Each object has a home and away property, and they are in turn, objects.
data:
{
  2018:
  [
    {
      "home": { },
      "away": { }
    },

  ]
},


home and away each have team and goals properties. In the first half of the 2018/2019 season, Liverpool hosted Arsenal at home. Thus, home's team property is "Liverpool" and away's team property is "Arsenal". The score was 4 - 0 to Liverpool, and we fill in the goals properties accordingly.
data:
{
  2018:
  [
    {
      "home": { "team": "Liverpool", "goals" : 4},
      "away": { "team": "Arsenal", "goals" : 0}
    },
  ]
},


And then we fill in the second match between Arsenal and Liverpool - the return fixture ended in a 3 - 3 draw.
data:
{
  2018:
  [
    {
      "home": { "team": "Liverpool", "goals" : 4},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 3},
      "away": { "team": "Liverpool", "goals" : 3}
    },

  ]
},


And we will fill in the rest of the fixtures between Manchester City and Liverpool, and Manchester City and Arsenal.
2018:
[
  {
    "home": { "team": "Liverpool", "goals" : 4},
    "away": { "team": "Arsenal", "goals" : 0}
  },
  {
    "home": { "team": "Arsenal", "goals" : 3},
    "away": { "team": "Liverpool", "goals" : 3}
  },
  {
    "home": { "team": "Manchester City", "goals" : 3},
    "away": { "team": "Arsenal", "goals" : 1}
  },
  {
    "home": { "team": "Arsenal", "goals" : 0},
    "away": { "team": "Manchester City", "goals" : 3}
  },
  {
    "home": { "team": "Manchester City", "goals" : 5},
    "away": { "team": "Liverpool", "goals" : 0}
  },
  {
    "home": { "team": "Liverpool", "goals" : 4},
    "away": { "team": "Manchester City", "goals" : 3}
  }       
   
]


... and the rest of the seasonal data.
data:
{
  2018:
  [
    {
      "home": { "team": "Liverpool", "goals" : 4},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 3},
      "away": { "team": "Liverpool", "goals" : 3}
    },
    {
      "home": { "team": "Manchester City", "goals" : 3},
      "away": { "team": "Arsenal", "goals" : 1}
    },
    {
      "home": { "team": "Arsenal", "goals" : 0},
      "away": { "team": "Manchester City", "goals" : 3}
    },
    {
      "home": { "team": "Manchester City", "goals" : 5},
      "away": { "team": "Liverpool", "goals" : 0}
    },
    {
      "home": { "team": "Liverpool", "goals" : 4},
      "away": { "team": "Manchester City", "goals" : 3}
    }          
  ],
  2019:
  [
    {
      "home": { "team": "Liverpool", "goals" : 5},
      "away": { "team": "Arsenal", "goals" : 1}
    },
    {
      "home": { "team": "Arsenal", "goals" : 1},
      "away": { "team": "Liverpool", "goals" : 1}
    },
    {
      "home": { "team": "Manchester City", "goals" : 3},
      "away": { "team": "Arsenal", "goals" : 1}
    },
    {
      "home": { "team": "Arsenal", "goals" : 0},
      "away": { "team": "Manchester City", "goals" : 2}
    },
    {
      "home": { "team": "Manchester City", "goals" : 2},
      "away": { "team": "Liverpool", "goals" : 1}
    },
    {
      "home": { "team": "Liverpool", "goals" : 0},
      "away": { "team": "Manchester City", "goals" : 0}
    }          
  ],
  2020:
  [
    {
      "home": { "team": "Liverpool", "goals" : 3},
      "away": { "team": "Arsenal", "goals" : 1}
    },
    {
      "home": { "team": "Arsenal", "goals" : 2},
      "away": { "team": "Liverpool", "goals" : 1}
    },
    {
      "home": { "team": "Manchester City", "goals" : 3},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 0},
      "away": { "team": "Manchester City", "goals" : 3}
    },
    {
      "home": { "team": "Manchester City", "goals" : 4},
      "away": { "team": "Liverpool", "goals" : 0}
    },
    {
      "home": { "team": "Liverpool", "goals" : 1},
      "away": { "team": "Manchester City", "goals" : 3}
    }          
  ],
  2021:
  [
    {
      "home": { "team": "Liverpool", "goals" : 3},
      "away": { "team": "Arsenal", "goals" : 1}
    },
    {
      "home": { "team": "Arsenal", "goals" : 0},
      "away": { "team": "Liverpool", "goals" : 3}
    },
    {
      "home": { "team": "Manchester City", "goals" : 1},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 0},
      "away": { "team": "Manchester City", "goals" : 1}
    },
    {
      "home": { "team": "Manchester City", "goals" : 1},
      "away": { "team": "Liverpool", "goals" : 1}
    },
    {
      "home": { "team": "Liverpool", "goals" : 1},
      "away": { "team": "Manchester City", "goals" : 4}
    }          
  ],
  2022:
  [
    {
      "home": { "team": "Liverpool", "goals" : 4},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 0},
      "away": { "team": "Liverpool", "goals" : 2}
    },
    {
      "home": { "team": "Manchester City", "goals" : 5},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 1},
      "away": { "team": "Manchester City", "goals" : 2}
    },
    {
      "home": { "team": "Manchester City", "goals" : 2},
      "away": { "team": "Liverpool", "goals" : 2}
    },
    {
      "home": { "team": "Liverpool", "goals" : 2},
      "away": { "team": "Manchester City", "goals" : 2}
    }          
  ],
  2023:
  [
    {
      "home": { "team": "Liverpool", "goals" : 1},
      "away": { "team": "Arsenal", "goals" : 1}
    },
    {
      "home": { "team": "Arsenal", "goals" : 3},
      "away": { "team": "Liverpool", "goals" : 1}
    },
    {
      "home": { "team": "Manchester City", "goals" : 0},
      "away": { "team": "Arsenal", "goals" : 0}
    },
    {
      "home": { "team": "Arsenal", "goals" : 1},
      "away": { "team": "Manchester City", "goals" : 0}
    },
    {
      "home": { "team": "Manchester City", "goals" : 1},
      "away": { "team": "Liverpool", "goals" : 1}
    },
    {
      "home": { "team": "Liverpool", "goals" : 1},
      "away": { "team": "Manchester City", "goals" : 1}
    }          
  ]

},


The currentData property is good as it is - it's a placeholder for any subset of data we will later derive from data. Let's go back to the HTML. Add a div before each drop-down list, and style it using the teamLogo CSS class.
<div id="teamsContainer">
  <div class="left">
    <div class="teamLogo">
      
    </div>
    <br />

    <select class="ddlTeam">
    
    </select>
  </div>
  <div class="right">
    <div class="teamLogo">
      
    </div>
    <br />

    <select class="ddlTeam">
    
    </select>
  </div>
</div>


Here's the styling in the CSS. The width and height are specified, and the background properties are meant to prepare the div for displaying team logos.
.right
{
  width: 49%;
  float: right;
}

.teamLogo
{
  width: 80%;
  height: 150px;
  margin: 0 auto 0 auto;
  background-repeat: no-repeat;
  background-size: contain;
  background-position: 50% 50%;
}


.ddlTeam
{
  width: 80%;
  height: 20%;
  text-align: center;
  margin: 0 auto 0 auto;
  border-radius: 3px;
  padding: 2px;
  display: block;
}


This should give you a good idea of what to expect.

Now, outside of the h2h object, here's some more code. Begin by declaring ddlTeam. Use the selectAll() method of d3 and pass in the class name of ddlTeam to get an array containing both drop-down lists.
    currentData:
    {
    
    }
  };

  var ddlTeam = d3.selectAll(".ddlTeam");
</script>


Get into the option tags of both drop-down lists by using selectAll() on ddlTeam. Then use the data() method to ensure that we'll be going through the teams property of the h2h object. We'll follow up with the enter() method.
var ddlTeam = d3.selectAll(".ddlTeam");

ddlTeam.selectAll("option")
.data(h2h.teams)
.enter()


And from this point, we'll append an option tag.
var ddlTeam = d3.selectAll(".ddlTeam");

ddlTeam.selectAll("option")
.data(h2h.teams)
.enter()
.append("option")


We then ensure that the first option is selected by default.
var ddlTeam = d3.selectAll(".ddlTeam");

ddlTeam.selectAll("option")
.data(h2h.teams)
.enter()
.append("option")
.property("selected", function(d, i)
{
    return i == 0;
})


We make sure that the value attribute and text of the option are the name property of each element in teams.
var ddlTeam = d3.selectAll(".ddlTeam");

ddlTeam.selectAll("option")
.data(h2h.teams)
.enter()
.append("option")
.property("selected", function(d, i)
{
    return i == 0;
})
.attr("value", function(d)
{
    return d.name;
})
.text(function(d)
{
    return d.name;
});


So here it is now! Both drop-down lists should have this list of teams.


At the end of this, run the changeTeams() method of the h2h object. We'll create that soon.
var ddlTeam = d3.selectAll(".ddlTeam");

ddlTeam.selectAll("option")
.data(h2h.teams)
.enter()
.append("option")
.property("selected", function(d, i)
{
    return i == 0;
})
.attr("value", function(d)
{
    return d.name;
})
.text(function(d)
{
    return d.name;
});

h2h.changeTeams();


Within the h2h object is where we create this. We have the logos and the drop-down lists obtained via the select() method of the d3 object, and assigned to variables.
    currentData:
    {
    
    },
    changeTeams: function()
    {
      var imgTeam1 = d3.select("#team1Logo");
      var ddlTeam1 = d3.select("#ddlTeam1");

      var imgTeam2 = d3.select("#team2Logo");
      var ddlTeam2 = d3.select("#ddlTeam2");
    }

  };
</script>


We then iterate through the teams array using a For loop. In the loop, we check if the current element's name property is equal to the first drop-down list's option tag's value attribute.
changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name)
  }

}


And if so, we set the first logo's background-image property to the current element's logo property.
changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name) imgTeam1.attr("style", "background-image: url(" + this.teams[i].logo + ")");
  }
}


We do the same for other drop-down list and logo.
changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name) imgTeam1.attr("style", "background-image: url(" + this.teams[i].logo + ")");
    if (ddlTeam2.node().value == this.teams[i].name)imgTeam2.attr("style", "background-image: url(" + this.teams[i].logo + ")");
  }
}


Of course, none of this will work if we don't put in the appropriate id attributes. And add the changeTeams() method to the conchange attribute of both drop-down lists.
<div id="teamsContainer">
  <div class="left">
    <div class="teamLogo" id="team1Logo">
      
    </div>
    <br />
    <select class="ddlTeam" id="ddlTeam1" onchange="h2h.changeTeams()">

    </select>
  </div>
  <div class="right">
    <div class="teamLogo" id="team2Logo">
      
    </div>
    <br />
    <select class="ddlTeam" id="ddlTeam2" onchange="h2h.changeTeams()">

    </select>
  </div>
</div>


By default the first is always selected, and the first team happens to be Arsenal.


Now I change the teams of each drop-down list, and this happens!


Let's handle a same-team scenario!

Now, when the same team is selected on each drop-down list, logically there can't be any data displayed because teams don't play against themselves in a Premier League Season. So let's handle that. Add a call to the getData() method inside changeTeams().
changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name) imgTeam1.attr("style", "background-image: url(" + this.teams[i].logo + ")");
    if (ddlTeam2.node().value == this.teams[i].name)imgTeam2.attr("style", "background-image: url(" + this.teams[i].logo + ")");
  }

  this.getData();
}


Create the method.
currentData: [],
getData: function()
{

},

changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name) imgTeam1.attr("style", "background-image: url(" + this.teams[i].logo + ")");
    if (ddlTeam2.node().value == this.teams[i].name)imgTeam2.attr("style", "background-image: url(" + this.teams[i].logo + ")");
  }

  this.getData();
}


In here, we begin much like we did in changeTeams().
getData: function()
{
  var ddlTeam1 = d3.select("#ddlTeam1");
  var ddlTeam2 = d3.select("#ddlTeam2");

  var team1 = ddlTeam1.node().value;
  var team2 = ddlTeam2.node().value;

},


Then we ensure that the currentData property is set to an empty array. This will be filled in as data is found.
getData: function()
{
  var ddlTeam1 = d3.select("#ddlTeam1");
  var ddlTeam2 = d3.select("#ddlTeam2");

  var team1 = ddlTeam1.node().value;
  var team2 = ddlTeam2.node().value;
  this.currentData = [];
},


However, if both drop-down lists have the same value, we exit early and currentData remains an empty array.
getData: function()
{
  var ddlTeam1 = d3.select("#ddlTeam1");
  var ddlTeam2 = d3.select("#ddlTeam2");

  var team1 = ddlTeam1.node().value;
  var team2 = ddlTeam2.node().value;
  this.currentData = [];

  if (team1 == team2) return;
},


Now add a call to renderLineChart().
changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name) imgTeam1.attr("style", "background-image: url(" + this.teams[i].logo + ")");
    if (ddlTeam2.node().value == this.teams[i].name)imgTeam2.attr("style", "background-image: url(" + this.teams[i].logo + ")");
  }

  this.getData();

  this.renderLineChart();
}


Create renderLineChart(). In it, we start by declaring chart and setting it to the SVG whose id is chart. And then using the html() method to clear it.
changeTeams: function()
{
  var imgTeam1 = d3.select("#team1Logo");
  var ddlTeam1 = d3.select("#ddlTeam1");

  var imgTeam2 = d3.select("#team2Logo");
  var ddlTeam2 = d3.select("#ddlTeam2");

  for (var i = 0; i < this.teams.length; i++)
  {
    if (ddlTeam1.node().value == this.teams[i].name) imgTeam1.attr("style", "background-image: url(" + this.teams[i].logo + ")");
    if (ddlTeam2.node().value == this.teams[i].name)imgTeam2.attr("style", "background-image: url(" + this.teams[i].logo + ")");
  }

  this.getData();

  this.renderLineChart();
},
renderLineChart: function()
{
  var chart = d3.selectAll("#chart");
  chart.html("");
}


We then check if currentData is an empty array.
renderLineChart: function()
{
  var chart = d3.selectAll("#chart");
  chart.html("");

  if (this.currentData.length == 0)
  {

  }

}


In chart, we pretty much just append a text tag (styled using the nodata CSS class) into the SVG with a message...
renderLineChart: function()
{
  var chart = d3.selectAll("#chart");
  chart.html("");

  if (this.currentData.length == 0)
  {
    chart
    .append("text")
    .attr("class", "nodata")
    .attr("x", "400px")
    .attr("y", "200px")
    .text("No data. Please select another pair of teams.");

  }
}


...and exit early.
renderLineChart: function()
{
  var chart = d3.selectAll("#chart");
  chart.html("");

  if (this.currentData.length == 0)
  {
    chart
    .append("text")
    .attr("class", "nodata")
    .attr("x", "400px")
    .attr("y", "200px")
    .text("No data. Please select another pair of teams.");
    return;
  }
}


In the CSS, we style nodata with font, color and alignment.
#chartContainer, #chart
{
  width: 800px;
  height: 400px;
  margin: 0 auto 0 auto;
}

.nodata
{
  font: bold 30px arial;
  fill: rgba(0, 0, 0, 0.5);
  text-anchor: middle;
}


So now, if we select the same team for both drop-down lists, we get a message!


Next

Getting and visualizing data for different teams.

No comments:

Post a Comment