Saturday, 7 December 2024

Shout-out to Lance Storm of Storm Wrestling!

2001. Picture a year where the Internet was a vastly different place. Ethernet had just taken off, rapidly eliminating those charming dialup modems. Websites, however, hadn't caught on that quickly, the vast majority (or at least, those I visited), still designed as though they had to abide by network bandwidth restrictions. Clunky graphics, clumsy animation, that sort of thing.

And it was during this time where I watched pro wrestling. In the 90s and early 2000s, pro wrestling was filled with all manner of colorful characters, and one such character was the Canadian pro wrestler Lance Evers, ring name Lance Storm. He had (and still has) his own website, Storm Wrestling.

There's a storm
coming.

It was a cozy spot on the Internet where Lance Storm discussed wrestling with fans and had his own little community. I was all for it, and even joined his book club!

But mostly, Mr Evers served as an inspiration for a young I.T Degree graduate and burgeoning web developer.

How was Storm Wrestling an inspiration?

Simple. No matter how crappy one might think the website was - mostly by today's standards, it was definitely cool back then - the fact remained that Lance Storm was not in the tech business. He was a pro wrestler.

And nevertheless, he registered a domain name and made a website. Again, a shitty website by today's standards, but still! Come on!

A relic of bygone times.

What kind of excuse did I, an actual web developer, have for not having my own website? None. Nada. Just pure laziness. Lack of motivation.

Registering a domain name and paying for hosting aren't extremely expensive. Honestly, if you wouldn't invest that much in your own career, why should any prospective employer?

No, I didn't get my own website right away. A lot still had to happen before I got off my ass to do shit. But it did kick-start the thinking process. And for that, I'll always be grateful.

Epilogue

It has been more than twenty years. And now I do have my own website and domain name. This has opened countless doors. Storm Wrestling inspired Teochew Thunder. Pretty poetic, wouldn't you say?


Stealing your thunder,
T___T

Monday, 2 December 2024

How much of the Artificial Intelligence hype is just hot air?

During an event held by Oracle in Singapore last month, I heard a speaker describe AI as "aspirin looking for a headache". On the other hand, Linus Torvalds was rather less poetic as he described AI, with characteristic bluntness, as "90% marketing and 10% reality".

The term "Artificial Intelligence" has infiltrated the public consciousness over the course of the past year. Tech products such as dating apps and search engines now boast AI-powered features. Even Meta's WhatsApp now has Meta AI built in, for no discernible purpose whatsoever. The consensus seems to be that adding the term "AI" to any product automatically raises its value in the eyes of consumers.

And sadly, they're right. Non-technical consumers tend to be a simple-minded bunch. On one hand, I feel like I should be outraged at this brazen exploitation of their gullibility. On the other hand, money needs to move in this economy, and if someone absolutely has to be parted from their money, it might as well be fools.

Shut up and take my money!

Is AI even all that revolutionary at the moment? Back in March, Sam Altman declared that OpenAI had produced ChatGPT 4 which had enhanced reasoning abilities. This report tells us that ChatGPT 4 beats 90% of humans. If we take this to be true, does it then follow that all the "AI-powered" features in other products up to now, have been bullshit?

As it turns out, very few people produce technology purely for the love of technology. It's a business like any other. And part of business, is hype. I can't go a day without seeing some ad for certification for AI pop up on my screen. My feed is full of news as to how AI is going to disrupt the workforce. It's starting to feel like people want this to happen a lot more than AI is actually capable of it. Because the perception of potentially huge profits, draws investors. Investors keep the money coming in. Few people have the patience to invest significantly in something that might be huge in a decade. However, if you can show progress now or soon, that's when the cash register sounds start ringing.

AI up to now

Currently, at the very basic levels, AI is being used in cheap entertainment. My Facebook feed is filled with the products of people amusing themselves with wacky AI image or video generations. Walls of text are similarly generated through idle conversations with bots.

At work, generative AI is used to rewrite paragraphs or even generate entirely new ones, in emails or in reports. Or create graphics for presentations. Even video. In short, nothing that wasn't possible before AI.

The problem is, the content generated can't be implicitly trusted. And even assuming that it can, such content generation isn't anything remotely groundbreaking. Creating presentations? Rewriting paragraphs and emails and cover letters? Bluntly put, it's kids' stuff. Unless your command of the English language was terrible to begin with, this use of generative AI won't increase quality of output. At best, the speed of which said output is produced, will increase tenfold. Quickly produced crap, however, is still crap.

When I first encountered ChatGPT, I regarded it as a fun new toy which could do amusing things like write poems, generate data about a known subject and make stuff up. And yes, even provide code for well-known algorithms. I certainly wasn't seeing ChatGPT as an equal, much less a threat to my professional existence.

The code generation capabilities were potentially interesting... but ultimately, nothing I couldn't accomplish with a Google search. And, of course, some judicious copy-pasting.

AI in the military?

If one were into conspiracy theories, we could even entertain the idea that AI is actually more advanced and being used by the military and secret organizations. The insipid content-generating version is the one that mere mortals like us are allowed to use, in order to distract us and lull us into a false sense of security. So while we're too busy using AI to genderswap our favorite movie stars and making crappy deepfake porn, some people are doing exactly what they want.

Depressing thought, eh? Anyway...


AI in the future

One question I keep getting asked is, will AI replace humans? Are our jobs in danger? Are programmer jobs in danger? I don't know, man. You've seen the state of Generative AI as it is now.

Text? We're talking about something that will cheerfully churn out chunks of text that looks reasonable but will probably not stand up to scrutiny. Just because something writes a lot of stuff in good English, doesn't mean the stuff is actually useful.

Images? You mean the art generators that keep rendering human hands with entirely too many fingers? The amount of effort it takes just to generate one useful image (which can never be replicated) is off-putting unless you're just doing it for recreational purposes. As for video, forget about Sora and its ilk replacing actors and film directors. The observations I made about art generators above, applies here, too.

If you can be replaced at your job by technology that produces such unusable rubbish, perhaps AI isn't the problem. Perhaps the problem is that your job is so repetitive and needs so little human ingenuity that a machine could do it, and do it a million times faster.

The other possible problem, in the case of artists and writers, is that consumers are Phillistines and don't require content to be all that good. They're happy with sucky AI generated monstrosities, as was the case recently when a local artist's work was plagiarised with AI.

This is not to say that AI will not eventually replace us. "Eventually" being the operative word. For this to happen, AI would have to hit some kind of critical point. Self-programming? Learning new logic instead of simply adding data to its memory? Actual creativity instead of merely approximating existing data from human beings?

And the golden question: When will this happen?

I don't know the answer to that question. No one does. There are too many possibilities. It could happen a hundred years from now. Or fifty. It could happen next week, or even after breakfast tomorrow. The only thing that's clear is, in its current form, AI is not even remotely close to being an average human replacement. However, once it arrives at that critical point, not only will AI have that human spark, it will have the speed gifted to it by technology. As to what would happen next, use your imagination.

In King Arthur's
Court.

This reminds me of a novel I once read, by Mark Twain, A Connecticut Yankee in King Arthur's Court. In the story, Hank Morgan time travels and finds himself in Camelot, making the acquiantance of the likes of Arthur, Merlin and Lancelot. The medieval English are initially hostile, but Morgan has a gun on him, and uses it to devastating effect. To the people of these times, this looks like powerful magic even though to us it's just, you know, a gun. They end up worshipping him as some kind of great wizard while Merlin looks jealously on.

Because, as we know, technology that is sufficiently advanced, is indistinguishable from magic.

Just for readers who are terrible with analogies, here are several parallels I can draw from that story to this situation. Generative AI looks like the gun. The promoters of AI technology such as Jensen Huang and Sam Altman, are Hank Morgan. And the rubes gushing about how transformative this new technology is, are, tragically, those primitive and technologically backward Englishmen.

Conclusion

Artificial Intelligence may or may not be increasing in actual technological depth, but its mentions are becoming increasingly ubiquitous, if not its actual usage. Time will tell if all this is smoke and mirrors.

Right now, with all the hype, AI doesn't actually need to be that good in order to gain traction. Perception is a huge part of this. As long as there are rubes around willing to uncritically swallow what the snake oil peddlers are selling, there'll be enough goodwill to carry it. But even that goodwill is going to run out at some point. By that time, let's see if AI has made any actual progress.

Maybe all these people who are hyping this up, who don't appear to have ever written a single line of code in their lives, are seeing something I'm not. I remain open to the possibility that I'm wrong about this. I want to be wrong about this. It would be beyond exciting if so.

Artificial regards,
T___T

Wednesday, 27 November 2024

Spot The Bug: The One-week Chinese Calendar

Hay hello to Spot The Bug, programmers! And have I got a doozy for you!

Damn these bugs!
They're everywhere!


I had this idea to make a calendar interface that would display the date... in Chinese. Like a Chinese calendar. So I broke out some HTML and CSS, and began populating it with JavaScript. It was the start of July this year. That fact will soon be relevant.

I ended up with this code. Upon page load, it should take today's date and render it in the style I wanted.
<!DOCTYPE html>
<html>
  <head>
    <title>Chinese Calendar</title>

    <style>
      .calframe
      {
        width: 250px;
        height: 250px;
        margin: 10% auto 0 auto;
        border: 3px solid #009900;
        color: #00AA00;
      }

      .calframe div
      {
        width: 100%;
        padding: 5px;      
      }

      #month
      {
        font-size: 1em;
      }

      #day
      {
        text-align: center;
        font-size: 4.5em;
        font-weight: bold;
      }
    </style>

    <script>
      function replaceNumbers()
      {
        var numbers =
        [
          "一", "二", "三", "四", "五", "六", "七", "八", "九", "十",
          "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
          "二十一", "二十二", "二十三", "二十四", "二十五", "二十六", "二十七", "二十八", "二十九", "三十",
          "三十一"
        ];

        var d = new Date();
        var month = d.getMonth();
        var day = d.getDay();

        document.getElementById("month").innerHTML = numbers[month] + "月";
        document.getElementById("day").innerHTML = numbers[day - 1];      

      }
    </script>
  </head>

  <body onload="replaceNumbers();">
    <div class="calframe">
      <div id="month"></div>
      <div id="day"></div>
    </div>
  </body>
</html>


And it worked too... well, for about a week. This was what it looked like on 4th July.


What Went Wrong

After a week, on 8th of July, it showed this. In Chinese, 1st July.


And 9th July. In Chinese, 2nd July.


10th July. In Chinese, 3rd July.


Why It Went Wrong

The fact that it worked for the first week, gave me a clue. I did some reading on the getDay() method in JavaScript. Turns out it was getting the day of the week instead of the day of the month!

This was why it worked on the first week - because 1st July started on a Monday! And 2nd July was on a Tuesday, and so on. So even though the output was wrong, it looked correct. Until the second week rolled around.

How I Fixed It

All I needed to do was change the getDay() method call to getDate().
var day = d.getDate();


And now it worked! It showed 10th July in Chinese... on 10th July!


Moral of the Story

This isn't the first time I've ever been fooled by the name of a method, with comical results. It's tempting to blame badly-named methods for this. As every programmer knows, naming things is pretty friggin' hard.

Still, it's on me. I should have been more aware of the methods I was attempting to use.

Good day (or date) to you!
T___T

Saturday, 23 November 2024

A Software Developer's Vacation in Malacca

Somewhere in the past month, I was flirting with the idea of taking a trip to Malacca City in Malacca, Malaysia. The wife was away, and I basically had free reign. One of my friends from Kuala Lumpur that I'd met on the Clubhouse app, had a counter-suggestion - we could meet up in Kuala Lumpur and take a day trip to Melaka!

Didn't sound too bad an idea, to be honest. I could use the company. And a local guide could help point out all the beautiful places I might miss.


My company was entering the busiest time of the entire year - the December festive period - and if I wanted to clear any leave at all, it was going to be then. Fingers crossed, my colleagues wouldn't bother me much during my trip. Being the head of a one-man Infocomm department comes with inherent liability, and these were the risks I had to manage.

The trip begins!

I arrived in Kuala Lumpur to spend the night in a hotel room, then my buddy came over in the morning in a charmingly beat-up Toyota the likes of I haven't seen since childhood. We had a simple and leisurely breakfast, then headed off. The morning traffic was horrendous until we hit the highway, and then we were looking at miles of open road as the palm trees whizzed by.



When we finally arrived in Malacca after a couple hours, I was greeted by the sight of what looked like a huge inflatable dragon coiling around the main archway. It was a weird cross between cheesy and oddly majestic. It did occur to me that this area looked strikingly Chinese.

The day was hot, but due to a sudden shower an hour before, the ground was wet with puddles. Not a great combination.

Jonker Street 

Now this was one part that reminded me of Armenian Street in Penang's Georgetown. Tourists milling around was a dead giveaway, but also the little quaint cafes here and there that looked too "touristy" to be authentic. The numerous shops selling all manner of Malaysia-themed knick-knacks was also a huge callback to my time in Penang.


I succumbed to the temptation and bought a refrigerator magnet. And only because it looked hand-made.


Somewhere along the street was this cheesy monument of some seriously ripped dude with this shit-eating grin. To this day, I'm not sure if that bust was built in earnest or mockery. This guy was apparently a bodybuilder in addition to being a politician.


I had the chance to look out along the Malacca River. Nice. Reminded me of Singapore River, except without the glitzed-up banks.

Food 

We had a break at one of the numerous places advertising Vietnam Coffee. Let me just say, for the record, that Vietnam Coffee just isn't Vietnam Coffee if you're not drinking it while squatting on the roadside on a tiny stool in Saigon as motorcycle traffic roars by you... but on a hot day like this, it worked in a pinch.


Later on, we had chicken rice in one of the numerous shops. The specialty appeared to be balls rolled from chicken rice... but honestly I ate it just to be able to say I've done it before. What was more interesting were the bean sprouts - they were large, fat and crunchy. I was told that they were a special breed known as "Ipoh" sprouts. Nice!

Museum and Art Gallery 

Lunch was done and we headed off across the street to this red building, called the Dutch Square. It used to be a fort and was now home to a museum and art gallery, among other things. For lack of something more obvious to do, the museum was first.


Now, honestly, there've been many people who told me to "read a History book" during online arguments, as though that qualified as the final word in any debate. History just happens to be one of my least favorite subjects. What I was about to see, however, was a virtual smorgasbord of violence.



You see, Malacca used to be an object of contention between Malaysians, the Dutch and the Portugese. Apparently the Malacca Straits were very important territory. We went through floor after floor of paintings, text and dioramas depicting acts of bloody battle. Stabbings, shootings, blood and agony, it was all there. Not for the faint of heart, that's for sure.

I saw weapons on display too - sabers and guns, with bullets the size of the chicken rice balls I'd eaten earlier. Military uniforms. Model ships. And my absolute favorite... miniature replicas of Malacca back in the day, depicting how it looked like at different periods in history. I could have stared at this shit for hours.



This was capped off by the sight of this badass statue of Admiral Cheng Ho at the rooftop.



The next part of this visual tour was the art gallery just next door. And, to be honest, there was quite a bit of art on display that I just didn't get. Some of it was historical, and those were actually the easiest to look at. The more abstract pieces? Call me a Philistine, but not so much.

The last part 

After leaving Jonker, we made a side detour. It was a white building by the seaside called the Encore Melaka, and it was an absolutely beautiful theater. Neither of us really felt like shelling out a hundred bucks for about three hours of their entertainment, so we just hung around outside.


And, in my case, took pictures like some tourist. Which I was.


By the sea, this sign proudly proclaimed the area to be The Maritime Silke Road, and even provided coordinates. Shit, this place was all kinds of fancy.

The curse struck again... 

Almost every day, I'd review my messages and see that there was stuff to attend to. Some of it I could defer till I got back; the others I had to attend to by the next day.

Honestly, it was annoying AF. It was like, the effort I'd put into an automatic vacation message and informing my co-workers, just didn't exist.


But this is what happens when you're a one-man Infocomm department - there are no reasonable expectations of a complete day off. Something goes wrong, and even if it could be looked into by someone else, the first instinctual response is to contact me by WhatsApp or send me a MS Teams message.

I'm not blameless in this regard though - I get such a summons and even though I'm supposed to be on vacation, I can't help myself. I just have to respond. This is something I need to seriously work on. As to how, for the moment, I have no clue.

What a vacation!

Short, but sweet. For real, though, this struck me as a good place to chill out for an entire afternoon if I felt like it. Maybe even for a few afternoons. And as of last week (or earlier) there are direct flights from Singapore to Malacca.

Speaking of which, I actually experienced the biometric scan when returning to Singapore's Changi Airport, days after it was reported in the local paper. Pretty cool. Traveling in and out of Singapore as a citizen wasn't a huge hassle to begin with, but this is going to make things even easier.

Stay tuned for more vacation updates!

Your travelling techie,
T___T

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