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

No comments:

Post a Comment