Sunday, 17 November 2024

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

Welcome back! We'll be adding stuff to some of the methods already written, and adding some new properties and methods to the h2h object.

Let's begin by modifying the getData() method. This code, by virtue of its very placement, runs only if team1 is not equal to team2. We use a For loop to iterate through the data array. Since we already know the seasons go from 2018 to 2023, we can cheat a little and use season as an index. Our objective is to to extract an array of matches which feature team1 against team2, regardless of which team is home or away.
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;

  for (var season = 2018; season <= 2023; season ++)
  {

  }

},


In each iteration, we have another For loop that traverses through the current object.
for (var season = 2018; season <= 2023; season ++)
{
  for (var i = 0; i < this.data[season].length; i++)
  {

  }

}


Declare h and a. h is the name of the home team and a is the name of the away team. Then declare score1 and score2 with default values of 0.
for (var season = 2018; season <= 2023; season ++)
{
  for (var i = 0; i < this.data[season].length; i++)
  {
    var h = this.data[season][i].home;
    var a = this.data[season][i].away;
    var score1 = 0;
    var score2 = 0;

  }
}


We only proceed if team1 is the home team and team2 is the away team, or team2 is the home team and team1 is the away team. Basically, if the current object does not represent a match between team1 and team2, use the continue statement to move on.
for (var season = 2018; season <= 2023; season ++)
{
  for (var i = 0; i < this.data[season].length; i++)
  {
    var h = this.data[season][i].home;
    var a = this.data[season][i].away;
    var score1 = 0;
    var score2 = 0;
  
    if (!((h.team == team1 && a.team == team2) || (h.team == team2 && a.team == team1))) continue;
  }
}


Now, we use If blocks. There are only two possible cases at this point - team1 or team2 is home. If home, we set score1 and score2.
for (var season = 2018; season <= 2023; season ++)
{
  for (var i = 0; i < this.data[season].length; i++)
  {
    var h = this.data[season][i].home;
    var a = this.data[season][i].away;
    var score1 = 0;
    var score2 = 0;
  
    if (!((h.team == team1 && a.team == team2) || (h.team == team2 && a.team == team1))) continue;
  
    if (h.team == team1)
    {
      score1 = h.goals;
      score2 = a.goals;
    }

  }
}


Same if team2 is home.
for (var season = 2018; season <= 2023; season ++)
{
  for (var i = 0; i < this.data[season].length; i++)
  {
    var h = this.data[season][i].home;
    var a = this.data[season][i].away;
    var score1 = 0;
    var score2 = 0;
  
    if (!((h.team == team1 && a.team == team2) || (h.team == team2 && a.team == team1))) continue;
  
    if (h.team == team1)
    {
      score1 = h.goals;
      score2 = a.goals;
    }
  
    if (h.team == team2)
    {
      score1 = a.goals;
      score2 = h.goals;
    }  

  }
}


Then we'll push the data into currentData as an object.
for (var season = 2018; season <= 2023; season ++)
{
  for (var i = 0; i < this.data[season].length; i++)
  {
    var h = this.data[season][i].home;
    var a = this.data[season][i].away;
    var score1 = 0;
    var score2 = 0;
  
    if (!((h.team == team1 && a.team == team2) || (h.team == team2 && a.team == team1))) continue;
  
    if (h.team == team1)
    {
      score1 = h.goals;
      score2 = a.goals;
    }
  
    if (h.team == team2)
    {
      score1 = a.goals;
      score2 = h.goals;
    }
  
    this.currentData.push({ "season": season, "team1": team1, "team2": team2, "score1": score1, "score2": score2});
  }
}


Now let's go to the method renderLineChart(). In here, we add a call to the methods renderAxis_x(), renderAxis_y() and renderPlots(). This means that these methods will only get called if there's data in the currentData array, because if there was no data, the renderLineChart() method would end before getting to these method calls.
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;
  }

  this.renderAxis_x();
  this.renderAxis_y();
  this.renderPlots();

}


Add these three methods.
  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;
    }
  
    this.renderAxis_x();
    this.renderAxis_y();
    this.renderPlots();
  },
  renderAxis_x: function()
  {

  },
  renderAxis_y: function()
  {

  },
  renderPlots: function()
  {

  }

};


Time to work on the x-axis. We begin by declaring chart, and then using the selectAll() method of the d3 object to set it to the SVG with the id of chart.
renderAxis_x: function()
{
  var chart = d3.selectAll("#chart");
},


Here, we add a horizontal line styled using the axis_x CSS class.
renderAxis_x: function()
{
  var chart = d3.selectAll("#chart");

  chart
  .append("line")
  .attr("class", "axis_x")

},


Then we specify the x1, x2, y1 and y2 attributes of the line. Note that we use the margin property of h2h so that we can observe a buffer space within the SVG chart.
renderAxis_x: function()
{
  var chart = d3.selectAll("#chart");

  chart
  .append("line")
  .attr("class", "axis_x")
  .attr("x1", h2h.margin + "px")
  .attr("y1", (400 - h2h.margin) + "px")
  .attr("x2", (800 - h2h.margin) + "px")
  .attr("y2", (400 - h2h.margin) + "px");

},


We specify margin here. It's neater to just place properties together. I've set 40 as the number of pixels that the buffer takes up all round, but feel free to try something else.
currentData: [],
margin: 40,
getData: function()
{


Here's the styling for axis_x. It's just a grey thin line. To save time, let's have axis_y, x_tick and y_tick all styled the same way too.
#chartContainer, #chart
{
  width: 800px;
  height: 400px;
  margin: 0 auto 0 auto;
}

.axis_x, .x_tick, .axis_y, .y_tick
{
  stroke: rgba(50, 50, 50, 1);
  stroke-width: 1px;
}


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


See this line?


We will be adding ticks! In renderAxis_x(), declare hspacing and set it to the value returned by calling the getHorizontalSpacing() method. Pass in the length of currentData as an argument.
chart
.append("line")
.attr("class", "axis_x")
.attr("x1", h2h.margin + "px")
.attr("y1", (400 - h2h.margin) + "px")
.attr("x2", (800 - h2h.margin) + "px")
.attr("y2", (400 - h2h.margin) + "px");

var hspacing = this.getHorizontalSpacing(h2h.currentData.length);


Add getHorizontalSpacing(). This method is supposed to calculate how much spacing there should be between each season's data point, assuming there is limit to the horizontal space available and we need to divide that equally between the data points. To that end, we will return 800, less margin two times, and the remainder will be divided by the value of the parameter maxSeasons. And because it's better to return a whole number, we'll use the Math object's floor() method on the result.
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;

  for (var season = 2018; season <= 2023; season ++)
  {
    for (var i = 0; i < this.data[season].length; i++)
    {
      var h = this.data[season][i].home;
      var a = this.data[season][i].away;
      var score1 = 0;
      var score2 = 0;

      if (!((h.team == team1 && a.team == team2) || (h.team == team2 && a.team == team1))) continue;

      if (h.team == team1)
      {
        score1 = h.goals;
        score2 = a.goals;
      }

      if (h.team == team2)
      {
        score1 = a.goals;
        score2 = h.goals;
      }

      this.currentData.push({ "season": season, "team1": team1, "team2": team2, "score1": score1, "score2": score2});
    }
  }
},
getHorizontalSpacing: function(maxSeasons)
{
  return Math.floor((800 - h2h.margin - h2h.margin) / maxSeasons);
},

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();
},


Now that we have hspacing, let's get back to the renderAxis_x() method. We want to append line tags to chart, styled using the x_tick CSS class. We'll specify currentData as data.
chart
.append("line")
.attr("class", "axis_x")
.attr("x1", h2h.margin + "px")
.attr("y1", (400 - h2h.margin) + "px")
.attr("x2", (800 - h2h.margin) + "px")
.attr("y2", (400 - h2h.margin) + "px");

var hspacing = this.getHorizontalSpacing(h2h.currentData.length);

chart.selectAll("line.x_tick")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "x_tick")


We then specify x1 and x2. They have the same value, because the ticks along the x-axis are basically short vertical lines. For both of them, we make sure that there's a buffer at the beginning by adding margin, then add hspacing multiplied by the current index, i. This means, progressively, each data point's x1 and x2 properties will move further from the left.
chart.selectAll("line.x_tick")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "x_tick")
.attr("x1", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})
.attr("x2", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})


Then we have y1. We take the maximum height, 400, less margin, as the starting point.
chart.selectAll("line.x_tick")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "x_tick")
.attr("style", function(d, i)
{
  return (i == 0 || i % 2 == 0 ? "" : "");
})
.attr("x1", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})
.attr("y1", (400 - h2h.margin) + "px")
.attr("x2", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})


For y2, we do the same with y1, except that we subtract one quarter of margin from the result.
chart.selectAll("line.x_tick")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "x_tick")
.attr("style", function(d, i)
{
  return (i == 0 || i % 2 == 0 ? "" : "");
})
.attr("x1", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})
.attr("y1", (400 - h2h.margin) + "px")
.attr("x2", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})
.attr("y2", (400 - (h2h.margin - (h2h.margin / 4))) + "px");


See the ticks?


Now we're going to add labels. In chart, we add text tags styled using the CSS class x_label.
chart.selectAll("line.x_tick")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "x_tick")
.attr("x1", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})
.attr("y1", (400 - h2h.margin) + "px")
.attr("x2", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})
.attr("y2", (400 - (h2h.margin - (h2h.margin / 4))) + "px");

chart.selectAll("text.x_label")
.data(h2h.currentData)
.enter()
.append("text")
.attr("class", "x_label")  


The x attribute will utilize a formula, the same that we used for the ticks.
chart.selectAll("text.x_label")
.data(h2h.currentData)
.enter()
.append("text")
.attr("class", "x_label")
.attr("x", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})


The y attribute is just a formula that will anchor the text tag just below the x-axis line.
chart.selectAll("text.x_label")
.data(h2h.currentData)
.enter()
.append("text")
.attr("class", "x_label")
.attr("x", function(d, i)
{
  return (h2h.margin + (i * Math.floor((800 - h2h.margin - h2h.margin) / h2h.currentData.length ))) + "px";
})
.attr("y", (400 - (h2h.margin - (h2h.margin / 2))) + "px")


Finally, the text attribute will be the season property of the current element. However, since there are two matches per season, so we only want the season label to appear for one of them. Thus, we have a conditional block to test if the current index, i, is 0 or an even number (using the Modulus operator). If this is true, the label is blank.
chart.selectAll("text.x_label")
.data(h2h.currentData)
.enter()
.append("text")
.attr("class", "x_label")
.attr("x", function(d, i)
{
  return (h2h.margin + (i * Math.floor((800 - h2h.margin - h2h.margin) / h2h.currentData.length ))) + "px";
})
.attr("y", (400 - (h2h.margin - (h2h.margin / 2))) + "px")
.text(function(d, i)
{
  return (i == 0 || i % 2 == 0 ? "" : );
});


If not, we use the current element's season parameter, and add the following year to it.
chart.selectAll("text.x_label")
.data(h2h.currentData)
.enter()
.append("text")
.attr("class", "x_label")
.attr("x", function(d, i)
{
  return (h2h.margin + (i * Math.floor((800 - h2h.margin - h2h.margin) / h2h.currentData.length ))) + "px";
})
.attr("y", (400 - (h2h.margin - (h2h.margin / 2))) + "px")
.text(function(d, i)
{
  return (i == 0 || i % 2 == 0 ? "" : d.season + " / " + (d.season + 1));
});


Here, we style x_label. To save time, let's define this as the styling for y_label as well. We want to specify the font, color and make sure that the text-anchor property is set to middle.
.axis_x, .x_tick, .axis_y, .y_tick
{
  stroke: rgba(50, 50, 50, 1);
  stroke-width: 1px;
}

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


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


This is how the labels will appear.


Now for the y-axis! A lot of the actions we take here will be analogous to what we did for the x-axis. Add code to the renderAxis_y() method. Here' we're adding the line to chart and setting the styling to the CSS class axis_y. The rest of the properties - x1, x2, y1 and y2 are hopefully self-explanatory by now.
renderAxis_y: function()
{
  var chart = d3.selectAll("#chart");

  chart
  .append("line")
  .attr("class", "axis_y")
  .attr("x1", h2h.margin + "px")
  .attr("y1", h2h.margin + "px")
  .attr("x2", h2h.margin + "px")
  .attr("y2", (400 - h2h.margin) + "px");

},


Here's the line!


We then define max as the highest number of goals scored in any match within the current dataset, currentData. For this, we use d3's max() method and use a conditional block to compare score1 and score2, returning the higher value.
renderAxis_y: function()
{
  var chart = d3.selectAll("#chart");

  chart
  .append("line")
  .attr("class", "axis_y")
  .attr("x1", h2h.margin + "px")
  .attr("y1", h2h.margin + "px")
  .attr("x2", h2h.margin + "px")
  .attr("y2", (400 - h2h.margin) + "px");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
}


Now define vspacing as the value returned by running the getVerticalSpacing() method with max passed in as an argument.
renderAxis_y: function()
{
  var chart = d3.selectAll("#chart");

  chart
  .append("line")
  .attr("class", "axis_y")
  .attr("x1", h2h.margin + "px")
  .attr("y1", h2h.margin + "px")
  .attr("x2", h2h.margin + "px")
  .attr("y2", (400 - h2h.margin) + "px");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
  var vspacing = this.getVerticalSpacing(max);
}


We then define getVerticalSpacing() as a method. Instead of using the number of seasons like we did for getHorizontalSpacing(), we define the vertical space available for each data point from the maximum number of goals.
getHorizontalSpacing: function(maxSeasons)
{
  return Math.floor((800 - h2h.margin - h2h.margin) / maxSeasons);
},
getVerticalSpacing: function(maxGoals)
{
  return Math.floor((400 - h2h.margin - h2h.margin) / maxGoals);
},

changeTeams: function()
{


We want an array of goal values, from 1 to max. To do that, we define goalsArr as an empty array, then using a For loop to iterate from 1 to max, we push each value to goalsArr.
var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
var vspacing = this.getVerticalSpacing(max);

var goalsArr = [];
for (var i = 1; i <= max; i++)
{
  goalsArr.push(i);
}  


Now we start adding ticks to the y-axis. These are line tags, appended to chart, styled using the y_tick CSS class. And we use goalsArr as the data source.
var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
var vspacing = this.getVerticalSpacing(max);

var goalsArr = [];
for (var i = 1; i <= max; i++)
{
  goalsArr.push(i);
}      

chart.selectAll("line.y_tick")
.data(goalsArr)
.enter()
.append("line")
.attr("class", "y_tick")


Since it's a horizontal line, x1 and x2 should be different. x1 will always be the same value as margin, which means the line starts horizontally at margin. And x2 will be margin less a quarter of margin.
chart.selectAll("line.y_tick")
.data(goalsArr)
.enter()
.append("line")
.attr("class", "y_tick")
.attr("x1", h2h.margin + "px")
.attr("x2", (h2h.margin - (h2h.margin / 4)) + "px")


y1 and y2 will be the same. We want a buffer space of margin, and use the index, i, to multiply by vspacing, and add the result to margin. In other words, we do what we did for the x-axis, except vertically.
chart.selectAll("line.y_tick")
.data(goalsArr)
.enter()
.append("line")
.attr("class", "y_tick")
.attr("x1", h2h.margin + "px")
.attr("y1", function(d)
{
  return ((400 - h2h.margin) - (d * vspacing)) + "px";
})

.attr("x2", (h2h.margin - (h2h.margin / 4)) + "px")
.attr("y2", function(d)
{
  return ((400 - h2h.margin) - (d * vspacing)) + "px";
});


The ticks.

We're adding labels next. So it's text tags appended to chart, styled using the CSS class y_label. Again, the dataset is goalsArr.
  chart.selectAll("line.y_tick")
  .data(goalsArr)
  .enter()
  .append("line")
  .attr("class", "y_tick")
  .attr("x1", h2h.margin + "px")
  .attr("y1", function(d)
  {
    return ((400 - h2h.margin) - (d * vspacing)) + "px";
  })
  .attr("x2", (h2h.margin - (h2h.margin / 4)) + "px")
  .attr("y2", function(d)
  {
    return ((400 - h2h.margin) - (d * vspacing)) + "px";
  });

  chart.selectAll("text.y_label")
  .data(goalsArr)
  .enter()
  .append("text")
  .attr("class", "y_label")  
  
},


We use the text() method to make goalsArr's current element's value, the text value.
chart.selectAll("text.y_label")
.data(goalsArr)
.enter()
.append("text")
.attr("class", "y_label")
.text(function(d)
{
  return d;
});  


The x attribute will be a function of margin. I'm going to make it half of margin, less 10 pixels.
chart.selectAll("text.y_label")
.data(goalsArr)
.enter()
.append("text")
.attr("class", "y_label")
.attr("x", ((h2h.margin / 2) - 10) + "px")
.text(function(d)
{
  return d;
});


The y attribute will depend on the current element. We have the maximum height, 400, less the margin. Then we have the value d multiplied by vspacing, subtracted from the result. After that, we add 5 pixels to align the label.
chart.selectAll("text.y_label")
.data(goalsArr)
.enter()
.append("text")
.attr("class", "y_label")
.attr("x", ((h2h.margin / 2) - 10) + "px")
.attr("y", function(d)
{
  return ((400 - h2h.margin) - (d * vspacing) + 5) + "px";
})

.text(function(d)
{
  return d;
});


You can see that the maximum number of goals scored by one team in matches between Manchester City and Arsenal is 5. This is reflected in the y-axis.


Finally, the plots!

We move on to the renderPlots() method. It starts similar to renderAxis_y(), defining chart and max the same way. For good measure, we'll also need hspacing and vspacing, utilizing the getHorizontalSpacing() and getVerticalSpacing() methods respectively.
renderPlots: function()
{
  var chart = d3.selectAll("#chart");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
  var vspacing = this.getVerticalSpacing(max);
  var hspacing = this.getHorizontalSpacing(h2h.currentData.length);

}


Declare color1. That's the color that the first team's (from the first drop-down list) data will be rendered in. Thus, we want to get the value from that drop-down list and reference teams, then get the color property value of the appropriate element. We're going to use the filter() method on the teams array.
renderPlots: function()
{
  var chart = d3.selectAll("#chart");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
  var vspacing = this.getVerticalSpacing(max);
  var hspacing = this.getHorizontalSpacing(h2h.currentData.length);

  var color1 = h2h.teams.filter((x) => {} );
}


We match team's current element's name property against ddlTeam1's selected value.
renderPlots: function()
{
  var chart = d3.selectAll("#chart");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
  var vspacing = this.getVerticalSpacing(max);
  var hspacing = this.getHorizontalSpacing(h2h.currentData.length);

  var color1 = h2h.teams.filter((x) => {return x.name == d3.select("#ddlTeam1").node().value;} );
}


There will be an array with only one element, so reference that and get the color property.
renderPlots: function()
{
  var chart = d3.selectAll("#chart");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
  var vspacing = this.getVerticalSpacing(max);
  var hspacing = this.getHorizontalSpacing(h2h.currentData.length);

  var color1 = h2h.teams.filter((x) => {return x.name == d3.select("#ddlTeam1").node().value;} )[0].color;
}


Do the same for color2, only we search ddlTeam2's selected value instead.
renderPlots: function()
{
  var chart = d3.selectAll("#chart");

  var max = d3.max(h2h.currentData, function(d) { return (d.score1 > d.score2 ? d.score1 : d.score2);});
  var vspacing = this.getVerticalSpacing(max);
  var hspacing = this.getHorizontalSpacing(h2h.currentData.length);

  var color1 = h2h.teams.filter((x) => {return x.name == d3.select("#ddlTeam1").node().value;} )[0].color;
  var color2 = h2h.teams.filter((x) => {return x.name == d3.select("#ddlTeam2").node().value;} )[0].color;
}


Here, we append circle tags to chart. The dataset is currentData, and the CSS class is node1.
var color2 = h2h.teams.filter((x) => {return x.name == d3.select("#ddlTeam2").node().value;} )[0].color;

chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")  


We esure that the circle's fill attribute is color1, and that it has a 5 pixel radius.
chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")


The cx attribute will use almost the same formula for the x1 and x2 properties of the ticks on the x-axis.
chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing)) + "px";
})


Except that we want to move them half a hspacing to the right.
chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})


And the cy property will account for a buffer the size of margin, then use the current element's score1 value to multiply by vspacing, to determine the circle's vertical positioning.
chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("cy", function(d)
{
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
});


You'll see the pale blue circles.


For this next step, just copy and paste what we did.
chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("cy", function(d)
{
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
});

chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("cy", function(d)
{
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
});  


And then make the necessary changes to display the goals for the second team.
chart.selectAll("circle.node1")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node1")
.attr("fill", color1)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("cy", function(d)
{
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
});

chart.selectAll("circle.node2")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node2")
.attr("fill", color2)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("cy", function(d)
{
  return ((400 - h2h.margin) - (d.score2 * vspacing)) + "px";
});


Now you can see the goals scored for Arsenal, rendered in scarlet.

Time to join the dots! We'll start with the first team's data. Append line tags to chart, and style them using the CSS class line1. As before, the dataset used is currentData. Make sure the stroke attribute is set to color1.
chart.selectAll("circle.node2")
.data(h2h.currentData)
.enter()
.append("circle")
.attr("class", "node2")
.attr("fill", color2)  
.attr("r", "5px")
.attr("cx", function(d, i)
{
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("cy", function(d)
{
  return ((400 - h2h.margin) - (d.score2 * vspacing)) + "px";
});  

chart.selectAll("line.line1")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line1")
.attr("stroke", color1)  


The index value, i, is going to be important when we determine x1, x2, y1 and y2. If it's the very first element of currentData, we will start at the left end, so we return "0px" automatically. Otherwise, we'll follow what we did for the circle tags' cx attributes.
chart.selectAll("line.line1")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line1")
.attr("stroke", color1)  
.attr("x1", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})


For x2, we do the same except that since our destination is the previous node, we reference i minus 1 instead of just i. No need to worry about an invalid index because of the first If block.
chart.selectAll("line.line1")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line1")
.attr("stroke", color1)  
.attr("x1", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("x2", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + ((i - 1) * hspacing) + (hspacing / 2)) + "px";
})


For y1, again we return 0px if it's the first element in currentData. If not, we do what we did for the circle tags' cy attributes.
chart.selectAll("line.line1")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line1")
.attr("stroke", color1)  
.attr("x1", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("y1", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
})

.attr("x2", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + ((i - 1) * hspacing) + (hspacing / 2)) + "px";
})


y2 is the same, except that we reference i minus 1 rather than i.
chart.selectAll("line.line1")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line1")
.attr("stroke", color1)  
.attr("x1", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("y1", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
})
.attr("x2", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + ((i - 1) * hspacing) + (hspacing / 2)) + "px";
})
.attr("y2", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (h2h.currentData[i - 1].score1 * vspacing)) + "px";
});


In the CSS, we give line1 a 3 pixel stroke width. Do the same for line2.
.nodata
{
  font: bold 30px arial;
  fill: rgba(0, 0, 0, 0.5);
  text-anchor: middle;
}

.line1, .line2
{
  stroke-width: 3px;
}


You see the lovely pale blue lines of Manchester City's goal data?


Now do the same, but for the second team's data.
chart.selectAll("line.line1")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line1")
.attr("stroke", color1)  
.attr("x1", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("y1", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (d.score1 * vspacing)) + "px";
})
.attr("x2", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + ((i - 1) * hspacing) + (hspacing / 2)) + "px";
})
.attr("y2", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (h2h.currentData[i - 1].score1 * vspacing)) + "px";
});

chart.selectAll("line.line2")
.data(h2h.currentData)
.enter()
.append("line")
.attr("class", "line2")
.attr("stroke", color2)  
.attr("x1", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + (i * hspacing) + (hspacing / 2)) + "px";
})
.attr("y1", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (d.score2 * vspacing)) + "px";
})
.attr("x2", function(d, i)
{
  if (i == 0) return "0px";
  return (h2h.margin + ((i - 1) * hspacing) + (hspacing / 2)) + "px";
})
.attr("y2", function(d, i)
{
  if (i == 0) return "0px";
  return ((400 - h2h.margin) - (h2h.currentData[i - 1].score2 * vspacing)) + "px";
});  


From here, you can see that Arsenal lost by big margins until the 2023/2024 season, where they proceeded to draw with and even beat Manchester City. Quite the turnaround, hey?


Change Arsenal to Liverpool in the second drop-down list, and you'll see this change occur.


Some clean-up

Disable this.
div { outline: 0px solid red; }


And there's your chart, all cleaned up and ready to go.



All done!

I guess compiling and visualizing the data helped me realize just how remarkable Arsenal's most recent performances are, relative to how they were the past five to seven years, against Machester City and Liverpool in particular. This doesn't explain Liverpool's collapse in the 2023/2024 season, but that's another mystery for another day.

All that glitters is not goals!
T___T

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.

Thursday, 7 November 2024

What Iswaran's sentence means for those in positions of authority

On the 7th day of last month, former Minister for Transport and Member of Parliament for West Coast GRC, Singapore, Mr S.Iswaran, began serving a twelve month sentence in prison. This is old news, but I was on my annually enforced blogging break. 


Still, I'm going to talk about it today, for a few reasons that I'm about to outline below.

Iswaran as an MP

When I moved to West Coast back in 2014, Iswaran was my MP. That meant that he was in charge of the district. Any issues that I encountered in the neighborhood, with amenities, public areas and services, were supposed to be resolved by him. Barring a chance meeting at a coffeeshop, I did not see him at all in my ten years in West Coast. This was because I encountered no issues at all in these ten years; at least, none I couldn't handle on my own.

Some people will complain that they don't see their MP often. I don't understand that attitude. MPs should be seen and not heard; in fact, they should preferably not be seen either. MPs are public servants. They're not my friends. I don't even want to see my friends every day or even every week, so why would I want to see my MP at all? My MP's job is to fix any issues that I see him about. Iswaran went one better - he made sure I never needed to see him. I never needed to be forcibly reminded of his existence. He did his thing and stayed the hell out of my way.

Give the man his due; by my standards, he did an excellent job.

Deskbound.

People who want to see their MP often are probably the same kind of employers who simply have to see their employees bound strictly to their desks during working hours, doing God-knows-what, but preferably looking busy. How I feel about such nonsense has been well-documented; so suffice to say, this isn't my thing at all.

The other possible explanation could be that these people actually think of their MPs as friends or family, and imagine that their MPS feel the same way. I've got such terrible news for you kids, you're voters and barring becoming family via marriage or adoption, that's all you will ever be.

Also, that "friends" thing is what landed Iswaran in hot water in the first place. I'd be very careful about that.

Relating this case to my professional experience

S. Iswaran pleaded guilty to four charges of obtaining valuable items as a public servant, and one charge of obstruction of the course of justice. He was subsequently sentenced to twelve months jail by the presiding judge Vincent Hoong.

Some people have wondered about the severity of the sentence. It was about double what the prosecutor had requested. Judge Hoong had this to say.
Persons who accept appointments to high office take on the heavy responsibilities of their office along with the associated power and status, and should generally be regarded as having acted with greater culpability in abusing their position to obtain valuable gifts.


Phew. That's a lot of words to say: the higher the office, the more culpable they are. And guess what - in Singapore, at least, Justice Hoong was not wrong. Iswaran should have known better. He was no rank-and-file civil servant. He was Chief of the Ministry of Transport.

The case of Singapore warship RSS Courageous is a good situational study. Back in 2003, she was involved in a high-profile maritime collision with merchant vessel ANL Indonesia. In the ensuing chaos, four servicewomen lost their lives. The Officer of the Watch was a Lieutenant Chua Chue Teng, understudying and supervised by superior officer Lieutenant Ng Keng Yong. At the conclusion of the case, both of these officers were found guilty of "causing death by a negligent act not amounting to culpable homicide" and dealt with accordingly.

But what of the helmsman, who undoubtedly steered RSS Courageous to collision? The name was not even mentioned in court. For one, the helmsman was not the officer of the watch, and therefore not entrusted with the lives and safety of those on board. For another, being rank-and-file, the helmsman's job was to trust the officer of the watch and follow orders. The helmsman wasn't qualified to make independent decisions as to where to steer or how fast to go. Thus, in the event of a mishap due to a bad decision, the blame lies solely with the Officer of the Watch.

The higher up that
totem pole you are...

That's a lesson we should all take something from. The higher up the totem pole you are, the higher the standards you should be held to.

A few years ago, I worked for this jackass who had a very childish habit of leaving profanities and abusive language in the GitHub code base. People thought I was being oversensitive. Do I have anything against profanities? (Bitch, please. You're joking, right?) No, I took exception because the person doing it was also the CTO of the company. I held him to a higher standard, professionally, because he should know better.

This is why I save some pretty scathing criticism for the likes of Elon Musk. Not because I don't admire his chutzpah and accomplishments - I do, in spades. But because I think his online behavior is unbecoming of someone in his position. Do tonnes of other people do the same thing? Yes, of course, but they're not leaders of multi-million dollar tech companies, are they?

Similarly, a public servant of Iswaran's standing and experience cannot claim ignorance where pertinent laws apply. It's like an experienced software dev claiming not to know how to write FizzBuzz. Either that dev is alarmingly incompetent, or just not being entirely honest.

Finally...

While he was ultimately found guilty under the law, it's my opinion that Iswaran acted out of carelessness and complacency rather than avarice or worse, contempt for the law. Ultimately, he conducted himself like a gentleman and has faced his legal responsibilities with dignity. I wish him and his family only the best.


My valuable gift to you (heh heh),
T___T

Friday, 1 November 2024

Why people should (and shouldn't) hire older software developers

Seven years ago, I wrote this post on my 40th birthday. Today, as I turn 47 in a few days, here are some more thoughts.

On my website, I described myself as "an aging software developer". Some people have told me that this could reflect negatively on me. They're mistaken, but I forgive them - they aren't from the tech sector and don't know better. You see, when I say the word "aging", this is not me being humble. This is me flexing the fuck out.

Just showin' off.

But please, hear me out. I swear, I'm not going to pull out lazy clichés like "older programmers are more experienced, more mature, have more gravitas, etc" not just because they're lazy clichés, but also because they're not true. And if I have to explain why they're not true, perhaps this is not a conversation you're ready for.

And because I enjoy being contrary even against myself, I will follow that up by explaining why older devs aren't necessarily the best choice. In the same spirit, I will avoid the stereotypes of being inflexible, slow and outdated. Again, those are lazy clichés and we should rise above them.

Why older devs are nothing to sniff at

You see, software technology is an industry which demands constant reinvention. As a result, past a certain number of years in the business, older devs tend to go "fuck this constant re-learning. I'm gonna go drive a cab or something".

In short, it's an industry where you find few old folks. And you know what they say about being wary of old men in a trade where men die young. Well, programmers aren't dying per se, but they're certainly quitting once they hit a certain age, because, if one was just doing it for the money to begin with, at that point it's just not rewarding anymore.

And because software technology is an industry which demands constant reinvention, it almost goes without saying that anyone who's had to stick around for that long, has gone through quite a bit of that. Me personally, I went from desktop support to web portals, to commercial websites, to web and mobile applications, to having to shoulder the duties of an entire infocomm department all by my lonesome. Sure, one could say people who have survived that long in the industry are merely lucky, but very few people are that lucky.

Adaptability is key.

So, if you needed someone highly adaptable that could adapt to the constant change that defines this industry, who would you choose?

An enthusiastic youth with lots of potential to learn and grow and evolve and theoretically should be able to adapt? Or an older programmer who's actually evolved over and over through the years and survived to tell the tale?

The conventional wisdom, of course, is to go for the proven product rather than the one that has potential -in theory. Yes, some of us older folks can be rigid and stuck in our ways, but the nature of this industry weeds such people out fairly quickly. You're left with the people who are adaptable enough to survive this industry (because we have!), and in this day and age, that's no small thing.

Again, no argument would be complete without presenting the other side. And there are plenty of compelling reasons why the modern employer might not want an older developer.

Why you should avoid older devs

Older software developers, unless they totally mismanaged their wealth, tend to have money. The industry pays well, and even a mediocre dev like myself might be earning more than Middle Management at an SME. As such, you're not going to get one for cheap. We more than likely don't need the peanuts you're reserving for code monkeys.

Peanuts, anyone?

Manipulation. No matter how noble a person an employer thinks they are, this is an organization and as such, there's always something of that sort going on, to one degree or another. Against manipulation, many older devs have developed, if not outright immunity, at least a discomforting degree of resistance to it.

Past a certain age, most older devs already have whatever we ever wanted out of life. We're there. We're comfortable. We're not hungry and desperate as the younger ones probably are. We're not going to kill ourselves for "exposure". Or submit to opportunistic lowballing (c'mon, we all know it happens) just to add to our resume. And that is an absolute negative because cold as it may sound, it makes us less open to manipulation.

Older devs do come with experience, and part of that experience is security. We're generally zen about the fact that we'll probably die and be forgotten. We've come to terms with the realization that if we were really destined to do anything truly exceptional, statistically we would have done it decades ago. Most of us are too tired, or probably done too much, to feel that we need to prove a damn thing. And if you're the sort of employer who likes to make your staff jump through flaming hoops to prove themselves, again, that kind of security and self-assuredness makes us untenable as employees.

All in all, older developers aren't cheap, and we're not desperate or hungry enough to run through walls at your command. And I can't in good conscience paint that as a positive for employers.

In a nutshell

For the right kind of employer, older software developers are an exceptional resource.

Also, consider this - with the news that Artificial Intelligence is going to make programmers obsolete, whether it's true or not, this is going to impact the number of young developers available on the market. Because if younger programmers think that this career path is no longer viable, they're just going to leave, and who could blame them?

Us older programmers? We're dug in, and we have little to lose. Chew on that!

Old but gold,
T___T