Thursday, 3 July 2025

Like a Sailboat in a Teacup

Over some coffee and chat, an ex-classmate was reminiscing about how, back in the day when we were all teenage idiots, I got so pissed off at one of our mutual classmates that I didn't speak to him an entire year. This happened over thirty years ago, so I'm not sure what productive reason there was to even bring this up. Perhaps sometimes I give people the impression that I'm altogether too impressed with myself and need to be occasionally reminded that I was once a stupid kid.

Which really isn't saying anything much because we were all once young and stupid.

Old and chill.

Now, I don't want to explain things away with the oversimplification that age brings maturity. Not only is that statement intellectually lazy, it is also patently untrue. Let's get real; we all know old people who are immature AF. But what is true is that a wealth of experience, provided one is willing to learn from them, both widens and deepens perspective.

What happened thirty plus years ago

One of my classmates, said something insulting about me. I challenged him to say it again, and like a typical zero-EQ child, he actually did.

Why was I so affected? That's a point of interest; in fact, the main point I am trying to make today.

My world was really small.

It was a time when my world was a teacup filled with water, and I was a little sailboat in the middle of it. Any disruption to that tiny body of water would send ripples through it, and rock that boat violently.

Thus, that little incident rocked my tiny world. I felt disrespected, and in that tiny teacup, this was a huge deal. At that time, I hadn't yet learned the simple fact of life that being respected is often far less important than who respects you.

And now...

It no longer seems important. If I'm being honest, it stopped being important (or even relevant) decades ago. The reason it no longer seems important, is simple.

Decades have passed, and my world is no longer that teacup. At some point, I moved from that teacup into the ocean, and began swimming with the sharks. I began dealing with big-boy problems. I've had threats to my livelihood. Every change in technology. People with power over my career who actively dug their claws in, and made my work life as unpleasant as possible. Employers who resented every cent they paid me to put up with their nonsense, and were just looking for an excuse - any excuse - to get rid of me.

Swimming with the sharks.

And after dealing with all that, I'm supposed to obsess over some silly playground insult uttered thirty years ago? Why would I do that? Matter of fact, why would anyone do that?

Ultimately, my ex-classmate was a stupid kid who thought it was clever and witty to say some dumb hurtful shit to another stupid kid. That's all it ever amounted to. Experience has shown me that everybody is going through something. I might have felt a certain way back then, but did I stop to consider that maybe he was going through something as well, and his mouth just happened to move faster than his brain? No, it was all about me. Like I said earlier, I lived in a tiny world, in my tiny brain.

It's not that I've become more generous; rather, that there are far better things to obsess over. There's a recession coming. The world is at war. A.I threatens to disrupt everything. Like I said earlier, big-boy problems. Even old-man problems such as possible kidney failure, creaking knees and cholesterol levels.

In a nutshell

This is similar to what I said last year about not badmouthing former employers. Not because I'm above that (although I am), or because they deserve my consideration (some of them absolutely don't), but because I have real and present concerns. We all do.

May your teacup always be full,
T___T

Sunday, 29 June 2025

Film Review: M3GAN 2.0

We all knew a sequel was coming for the hit slasher M3GAN back in 2022, and here it is - the suitably titled M3GAN 2.0. It's a sequel in all but genre, as I'm about to explain shortly.


For some background, M3GAN was about a robot doll who interpreted its programming a little (OK, a lot) too literally and ended up killing people in very gruesome ways. At the end of the movie, she was destroyed, but her digital consciousness escaped into the ether. I mean, this is the Internet age, amirite?

Suffice to say, the titular robot returns in this sequel, but how does it stack up against the original? Let's find out.

Warning - bitchy murderbots ahead!

And also spoilers. But yeah, the killer robots are the meat of this movie - and with them, violence and profanity.

The Premise

M3GAN, the homicidal robot doll, has resurfaced in this sequel. In an obvious nod to the classic Terminator 2: Judgement Day, the former villain of the previous movie comes back as an ally to the protagonists of the sequel, defending them against another homicidal robot doll. What ensues is a wacky action movie and good popcorn fun.

But anyone hoping for a creepy horror-fest like the last outing, is going to be sorely disappointed. Right from the get-go, the movie wastes no time establishing that it is now more of a guns-ablazing action comedy. Still campy, snarky fun, but with a 180-degree twist.

The Characters

Allison Williams returns as Gemma. I was happy to see her back, if just for the continuity. I found her bland and uninspiring, even more so than in the last movie, if that were even possible. Which is a tragedy because I think Williams can actually act.

Violet McGraw also returns as Cady. She's grown up, and now she knows Aikido. I liked her in the original, and I like her here. Now she's moved on beyond being an orphan and has become the typical angsty teenager. The character has more to do beyond moping and grieving, and even helps move the plot along.

Jenna Davis and Amie Donald are, once again, the voice and body of M3GAN, respectively. Except that Amie Donald has now grown several inches, so they make M3GAN demand to be made "taller". It's hilarious if you watched the original and know the background of the joke. Jenna Davis seems to be having a blast delivering M3GAN's biting wit and deadpan sarcasm.

Ivanna Sakhno as AMELIA, acronym for Autonomous Military Engagement Logistics and Infiltration Android, the other murderous robot doll. She brings heavy vibes of Kristianna Lokken from Terminator 3: Rise of the Machines, in demeanor. She does OK, but to be fair, all she really has do do in terms of personality is... actually, nothing. They could have just put a CGI face on AMELIA and it wouldn't have changed much. I mean, her role here is purely physical anyway, right?

Jermaine Clement has the role of Alton Appleton, a disabled tech millionaire who comes off as a elitist sleazeball. He delivered some great ableist dad jokes ("a man of my standing", heh heh). I was sad that they killed him off so soon.

Timm Sharp is FBI agent Tim Sattler, a douchebag who learns the hard way that he's in over his head. The script went out of its way to paint him as incompetent, self-important and just not at all likeable. Perhaps just to give the audience something to cheer about when he inevitably got his ass handed to him.

Aristotle Athari as Christian Bradley, Gemma's's milquetoast boyfriend who - surprise, surprise - turns out to be the big bad. Could they have found someone whose face screamed I'm the bad guy any louder? Somehow I doubt it.

Jen Van Epps plays Tess once more. She has even less to do than the last time, and I think it's a damn shame because she's so watchable.

Brian Jordan Alvarez comes back as Cole, and he has significantly more to contribute this time, with some physical presence in the plot. He remains largely part of the background, though.

Amy Usherwood as Lydia, the therapist. It's good that she was included for the sake of continuity. It's even better that her one scene was kept mercifully short, because she really got on my nerves the last time round.

The Mood

M3GAN was dark and sinister. In a departure from its tech slasher horror roots, M3GAN 2.0 is now a tech action thriller. It begins with a scene in the Middle East that wouldn't be out of place in a James Bond movie, and then continues gleefully in that same vein throughout the movie. It's loud, colorful and vulgar, and M3GAN is its potty-mouthed avatar.

What I liked

M3GAN is a delightful shit-talker. If there's any part of this sequel that reminded me of why I enjoyed its predecessor, it has to be this. M3GAN delivers absolute zingers, decimating egos and self-esteem with measured vitriol. She's no longer creepy and eerily calm and seems stuck in the savage verbal mode she was in at the tail end of the last movie, but I liked it. This movie really missed the presence of Ronny Chieng, whose character died in the last movie. It feels like M3GAN inherited his lines.

This line, for instance.
Hold on to your vaginas!

The jokes landed, for the most part. The scenes of M3GAN's exasperation at getting stuck in the body of a Teletubby doll, were hilarious. M3GAN is getting increasingly humanized in this one, and I am here for it! There's also this part where AMELIA snaps a guy's neck and the camera immediately cuts to Breaking News on the TV. Absolutely hysterical.

AMELIA was fun to watch. I loved the scenes of her crawling down a pipe in the background, creeping out of a suitcase, and her murder sprees. Unlike most action flicks where you see a tiny girl beat up a team of trained henchmen twice her size, I didn't have to strain too hard to justify the sight to myself. We are talking about a robot with a metal skeleton here.

The previous Aesop is still in force - parents should be wary of leaving the parenting to tech devices. Though, this time, they've expanded it to responsible usage of A.I, or something to that effect, instead of summarily dismissing A.I as "good" or "bad". Interesting, but maybe a movie as obviously ridiculous as M3GAN 2.0 wasn't the best medium for it?

What I didn't

M3GAN broke out into song just like the last time, but this time I wasn't feeling it. This just felt like a waste of time.

This movie went on for two hours. Was there really that much content? The plot was way too convoluted, what with The Motherboard and shit. Trimming all this fat from the movie would have resulted in a leaner one with more time to spend on stuff that really mattered. Speaking of superfluous things, the gag of how Christian's name is pronounced, was silly, lame, and got too much air-time. Leaving it out altogether would have done this movie good.

I'm on the fence about the genre shift. It's always a huge gamble. Films like Happy Death Day and its sequel Happy Death Day 2U managed to make the switch from slasher to another genre (in this case, science-fiction) and not suck too much, but it's unreasonable to expect the same (questionable) success every time.

As clever as the movie tried to be with its plot twists, it was just as predictable as the last one. Lucky no one watches these movies for cleverness. Stick to your strengths, eh?

Conclusion

This was OK-ish. M3GAN 2.0 was a fun ride for the most part. But I will confess to feeling let down, because what originally attracted me to M3GAN was the slasher concept. As a consequence, I did not enjoy myself as much as I did watching the original.

Was it worth the watch though? Very much so, especially if you watched the original, and kept your expectations realistic. Exceedingly rare is the movie sequel that surpasses its predecessor. M3GAN 2.0 is not in the hallowed company of films like Terminator 2: Judgement Day and John Wick: Chapter 4, and no one should expect it to be.

I did want a sequel to M3GAN. I'm just not sure if I wanted this sequel.

My Rating

6.5 / 10

This was kind of m3h,
T___T

Monday, 23 June 2025

What Swimming Taught Me About Brooks' Law

Swimming is a great activity. It's good cardio, facilities are inexpensive and its ability to help me solve problems cannot be overestimated. Why's that, you may ask? Well, for the simple fact that when you're underwater slugging it out, there's no getting distracted by Social Media or the wife (bless her heart) snuggling up. No, your brain gets a regular supply of oxygen as your body moves, and that's when I do my best thinking.

Swimming is
good for you.

It was during one of these swim sessions where I realized something else about swimming - as an activity, it's startlingly similar to team activities like, you guessed it, software development.

But let's start at the beginning, shall we?

How it all started...

I was in the pool doing laps, on an idyllic weekend afternoon. I remember it being blazing hot. As I languidly knifed through the water with my Breast Stroke, I overtook a guy doing The Crawl. Quite handily, I might add. This is tremendous because The Crawl is considered the significantly more powerful technique. Plus, the guy was maybe ten years younger than I was. I was feeling pretty smug about the situation, overall.

Until we both got overtaken by this old geezer who just blew past us like we were a couple of chumps. Talk about a reality check right there.

Later on, I lounged by poolside and watched as this old man hobbled up the ladder. He seemed to be walking funny. Then I stared. This guy's entire right foot had been sawed off around the ankle. I had been outraced in the pool by a goddamn amputee.

Missing one foot.

Sometimes, I noticed something similar about other swimmers. They might be taller or more athletic-looking. They moved more furiously. But still, I often overtook them in the chillest way possible.

It occured to me, that, just like with the old man and his amputated foot, the way our bodies were built, just wasn't affecting our speed. After all, everyone's body has mostly the same parts but with infinite variances. Some have bigger feet. Some have different weight distributions across their bodies. Some have longer legs, more streamlined hips, things like that. These things may give one the edge in terms of speed, in addition to just putting in more effort.

But all things being equal, it's more a matter of understanding how to coordinate ones body for optimal results.

What does this mean?

Having been swimming regularly for years, I had taken the time to get comfortable with my own body and maximize my performance in the pool, with minimal effort. The old man, similarly, had probably taken decades to be able to swim this fast, this gracefully, with only one foot.

And that's what I think the crux of the matter is - coordination.

Proper limb positioning.

With the Breast Stroke, for example, the legs are often the means of propulsion while the arms are the stabilizers.

Some people put in more work than me in their Breast Stroke. They complete, say, two strokes per second while I go at a pace of one stroke every three seconds. How, then, do I overtake them? Actually, I don't. They hold themselves back.

You see, these vigorous movers are doing more work, but often, when their legs are propelling them forward, their arms are in the wrong position. The arms are spread out, adding underwater resistance and inhibiting the forward propulsion. When my legs are kicking out for the forward propulsion, my arms are straight ahead, fingertips pointed forward, for minimum underwater resistance. When my legs are curled and preparing to kick, my arms are similarly curled and preparing to straighten. There is no other position they would be in; this has been drilled repeatedly into muscle memory: when my legs curl, so do my arms. When they straighten, so do my arms.

And that's just limb positioning. There's more to be said for correct breathing - when to inhale and exhale. For example, a very uncontroversial statement is that inhaling with your head under water is a horrendous idea; I wouldn't recommend it.

All I outlined above, is coordination. And thus, I work less hard underwater, but move faster.

How Brooks' Law applies

There is a well-known saying in software development.
"Adding manpower to a late software project makes it later." - Brooks' Law.


And this is because, when more developers are added to a project, one also needs to factor in the time it takes to bring them up to speed, find their place and get to a point where they start contributing positively. Thus, more people does not necessarily mean greater speed. This was the point that Fred Brooks, the man who coined this phrase, was trying to make.

The more the merrier?

Similarly, in a hypothetical scenario where I grew a third leg (scrub your filthy minds, please) that extra limb would not necessarily speed me up in a swim. Until I took the time to acclimatize to it and use it productively, it might even slow me down.

The Watery Takeaways

More is not always merrier. Additional elements that get in the way of existing elements, is a sign of the need for better coordination, and not more elements

Also, more work does not necessarily lead to faster results. Though if you didn't know that by now, you probably haven't been in the workforce long enough.

And finally, coordination has a real time cost, and needs to be accounted for.

Just some swimple truth!
T___T

Thursday, 19 June 2025

Failure is an eventuality, not a possibility

Some years ago, I recounted a story I'd heard to a friend.

In ancient Japan, the Lord of a territory had two sons. The first son was an impressive physical specimen - in archery, swordsmanship, wrestling, and he never lost. The second son was competent, but compared to his brother, he often came up short. It was expected that the Lord would pass on the leadership of the clan to his first son.

To the first son's consternation, however, when the time came for the Lord to retire, it was the second son who inherited the territory. The first son demanded to know why, and his father explained.

Inheriting leadership.

While the first son had gained a reputation for excellence and never having tasted defeat, the second son gained a reputation for persistence. He had an understanding of both his strengths and his shortcomings, and had learned from his mistakes - traits that all capable leaders need to have. More importantly, he understood how to recover from failure.

A different takeaway? 

Somehow, my buddy's takeaway from this was that the Lord had made a foolish decision - one looks at track record and it was unquestionable that the first son should be the one to lead. From this, I can only conclude that this was because my friend had never been part of the tech industry, or its very specialized cousin, the cybersecurity industry.

You see, techies generally know (any techies who don't know this should probably find another, less taxing line of work) that in the world of cybersecurity, defense is always playing catch-up to attack. After all, a defense can only be devised for an attack method that has already been invented. Thus, if your system has never gone down due to a cybersecurity attack, it's usually down to two reasons. One, there are targets more tempting than you. You're simply not worth the effort. Two, you've just been lucky.

Anyone feel lucky?

Neither reason has anything to do with the excellence of your defenses. That's just not the world we live in.

Thus the real test is not whether one can remain undefeated in perpetuity but whether one can recover from defeat, and how quickly. That is why recovery plans are integral to I.T Security policies. Prevention strategies are also part of I.T Security policies, yes... but the general understanding is that it's not a matter of whether your system will be the victim of a strong and determined attack, but when.

In fact, one should be extremely aware of those who claim to have never failed or made a mistake. If they're telling the truth (and that's a huge if) it raises red flags as to their adaptability and resilience.

In this industry, you're judged by how well you recover from failure. Not by never having encountered it.

An MMA example 

There's a real-world example in Mixed Martial Arts that illustrates this perfectly.

Take Conor McGregor. I'm not a big fan of the man by any means. He seems like a jerk with a big mouth. Where it concerns his job as a fighting athlete, however, I have considerably more respect for him than I do for Ronda Rousey. (That's not to say I think I could take her; she'd probably kick my ass in ten seconds flat).

You see, they're both legends in the sport. Their athleticism and skill. Their personalities. The way they popularized the sport. However, there is one significant difference. Ronda Rousey dominated the women's circuit for years, until she encountered two defeats. After the first defeat, she went to pieces and it was a while before she came back. The second defeat, at the capable hands of Amanda Nunes, was an utter annihilation and ended in her retirement.



McGregor, on the other hand, has lost a hell of a lot more fights than Rousey. However, each time, he's always come back strong, louder and more obnoxious than ever, none the worse for wear. Never has he allowed a defeat to end his career the way it did for Rousey. While McGregor was used to defeat and took it in stride, a couple of defeats was all it took for Rousey to crumble. Because she had never tasted it and didn't know how to handle it.

While McGregor knew he wasn't infallible (and had made peace with the fact), Rousey allowed herself to believe she was.

That's why I feel that an experience that incorporates failures (and the lessons we learn from them) are infinitely more useful than an experience with nothing but success.

Conclusion 

True value lies in adaptability, not invincibility.

It's a matter of when, rather than if, you taste defeat. And when that happens, what really matters is how you react to it. Nobody is invincible and nothing is perfect. And until we come to terms with that fact, we're doomed to repeated failure.

For the win,
T___T

Monday, 16 June 2025

Web Tutorial: D3 Combo Chart

A couple months ago, I pontificated at length about quitting smoking and working on my chinup game. There was a combo chart mentioned, and collected data. It is this combo chart that we will build today, in D3. The data is in this CSV file.

This is going to be a bare bones chart, no fancy animations and whatnot. To that end, let's begin with the HTML and D3 import. You will see that in the HTML, I have also included a h1 tag, an SVG tag and a script tag.
<!DOCTYPE html>
<html>
  <head>
    <title>Quit Smoking!</title>

    <style>

    </style>

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

  <body>
    <h1>The Chinups - Cigarettes Combo Chart</h1>

    <svg>

    </svg>

    <script>

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


In the CSS, we set a width and height for the SVG, and some specs for the h1 tag. The specs for the SVG are more pertinent to this tutorial.
<style>
svg
{
  width: 800px;
  height: 500px;
}

h1
{
  font-family: verdana;
  font-size: 16px;
}

</style>


That really is it for the HTML. From this point on, it will be mostly about the JavaScript. In the script tag, we have a comboChart object. Aside from that, we also make a call to d3's csv() method, passing in the name of the file, combo.csv, and a callback. We're going to be developing these two blocks somewhat concurrently.
<script>
  let comboChart =
  {

  };

  d3.csv("combo.csv", function(data)
  {

  });
</script>


In comboChart, we have properties cigData and chinupData, which are arrays. We then have maxUnits and dataPoints, which are integers with a default value of 1.
<script>
  let comboChart =
  {
    cigData: [],
    chinupData: [],
    maxUnits: 1,
    dataPoints: 1

  };

  d3.csv("combo.csv", function(data)
  {

  });
</script>


When parsing combo.csv, the dataPoints property is set to the length of data.
<script>
  let comboChart =
  {
    cigData: [],
    chinupData: [],
    maxUnits: 1,
    dataPoints: 1
  };

  d3.csv("combo.csv", function(data)
  {
    comboChart.dataPoints = data.length;
  });
</script>


We then have a For loop to iterate through data. The figure in the Reps column gets pushed into the chinupData array. The figure in the Cigs column gets pushed into the cigData array.
<script>
  let comboChart =
  {
    cigData: [],
    chinupData: [],
    maxUnits: 1,
    dataPoints: 1
  };

  d3.csv("combo.csv", function(data)
  {
    comboChart.dataPoints = data.length;

    for (var i = 0; i < data.length; i++)
    {
      comboChart.chinupData.push(parseInt(data[i].Reps));
      comboChart.cigData.push(parseInt(data[i].Cigs));
    }

  });
</script>


We then define maxReps. Using the max() method of d3, we want maxReps to contain the maximum value in chinupData. We then do the same for maxCigs, using it to contain the maximum value in cigData.
d3.csv("combo.csv", function(data)
{
  comboChart.dataPoints = data.length;

  for (var i = 0; i < data.length; i++)
  {
    comboChart.chinupData.push(parseInt(data[i].Reps));
    comboChart.cigData.push(parseInt(data[i].Cigs));
  }

  var maxReps = d3.max(comboChart.chinupData);
  var maxCigs = d3.max(comboChart.cigData);

});


And then we set maxUnits to maxReps or maxCigs, whichever value is higher.
d3.csv("combo.csv", function(data)
{
  comboChart.dataPoints = data.length;

  for (var i = 0; i < data.length; i++)
  {
    comboChart.chinupData.push(parseInt(data[i].Reps));
    comboChart.cigData.push(parseInt(data[i].Cigs));
  }

  var maxReps = d3.max(comboChart.chinupData);
  var maxCigs = d3.max(comboChart.cigData);

  if (comboChart.maxUnits < maxReps) comboChart.maxUnits = maxReps;
  if (comboChart.maxUnits < maxCigs) comboChart.maxUnits = maxCigs;

});


Finally, we call the drawCharts() method from the comboChart object.
d3.csv("combo.csv", function(data)
{
  comboChart.dataPoints = data.length;

  for (var i = 0; i < data.length; i++)
  {
    comboChart.chinupData.push(parseInt(data[i].Reps));
    comboChart.cigData.push(parseInt(data[i].Cigs));
  }

  var maxReps = d3.max(comboChart.chinupData);
  var maxCigs = d3.max(comboChart.cigData);

  if (comboChart.maxUnits < maxReps) comboChart.maxUnits = maxReps;
  if (comboChart.maxUnits < maxCigs) comboChart.maxUnits = maxCigs;

  comboChart.drawCharts();
});


We've not created drawCharts() yet. Well, no time like the present! In it, use the select() method of d3 to get the sole SVG object, and set it to the variable svgChart. Then clear svgChart.
let comboChart =
{
  cigData: [],
  chinupData: [],
  maxUnits: 1,
  dataPoints: 1,
  drawCharts: function()
  {
    var svgChart = d3.select("svg");
    svgChart.html("");
  }

}


Next, run the drawAxes() method, passing in svgChart as an argument.
let comboChart =
{
  cigData: [],
  chinupData: [],
  maxUnits: 1,
  dataPoints: 1,
  drawCharts: function()
  {
    var svgChart = d3.select("svg");
    svgChart.html("");

    this.drawAxes(svgChart);
  }
}


We'll want to create this method. It has a parameter, chart. In drawCharts(), chart was already passed when drawAxes() was called.
drawCharts: function()
{
  var svgChart = d3.select("svg");
  svgChart.html("");

  this.drawAxes(svgChart);
},
drawAxes: function(chart)
{
    
}


We append one horizontal line and one vertical line. Bearing in mind that we have a 800 by 500 pixel square, and we want a 50 pixel buffer around the perimeter, this is how the x1, x2, y1 and y2 attributes are calculated. We use the CSS classes axesVertical and axesHorizontal.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");    

}


These two CSS classes described a thin grey line.
svg
{
  width: 800px;
  height: 500px;
}

h1
{
  font-family: verdana;
  font-size: 16px;
}

.axesVertical, .axesHorizontal
{
  stroke: rgba(100, 100, 100, 1);
  stroke-width: 1px;
}


Here be your starting lines.


We then want to determine the amount of pixels dedicated to each point plotted on the horizontal and vertical axes. Since we have already determined maxUnits (the highest value in the entire dataset) and dataPoints (the total number of data rows in the entire dataset), we can define pxPerUnit as available vertical space (500 - 50 - 50 = 400) divided by maxUnits and pxPerPoint as (800 - 50 - 50 = 700) divided by dataPoints. And since we only want whole numbers, we'll use the floor() method on these results.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);  
  
}


We then define scale as an empty array. We will populate scale with the y-positions we want on the vertical axis. Remember that we start from 450 and end with 50 because we're implementing a 50 pixel buffer on the maximum heigh of 500 pixels. And we do in in steps of pxPerUnit.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);

  var scale = [];
  for (var i = 450; i >= 50; i -= pxPerUnit)
  {
    scale.push(i);
  }   
 
}


And then we go through chart to append line tags that are styled using the CSS class scaleTick. These will be about 10 pixels in length. The data used will be the scale array, which we've already populated with the appropriated values.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);

  var scale = [];
  for (var i = 450; i >= 50; i -= pxPerUnit)
  {
    scale.push(i);
  }

  chart.selectAll("line.scaleTick")
  .data(scale)
  .enter()
  .append("line")
  .attr("class", "scaleTick")
  .attr("x1", "40px")
  .attr("y1", function(d)
  {
    return d + "px";
  })
  .attr("x2", "50px")
  .attr("y2", function(d)
  {
    return d + "px";
  });    

}


We'll add scaleTick to this CSS specification.
.scaleTick, .axesVertical, .axesHorizontal
{
  stroke: rgba(100, 100, 100, 1);
  stroke-width: 1px;
}


And the ticks appear.


Then of course, we're going to need text. We will insert text tags, styled using the scaleText CSS class. The data used will be scale again, and the y attribute will depend on the current element of scale. The text itself, will reflect what the current index of scale is. And this, not-so-coincidentally, will be the value that the specific tick on the axis represents!
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);

  var scale = [];
  for (var i = 450; i >= 50; i -= pxPerUnit)
  {
    scale.push(i);
  }

  chart.selectAll("line.scaleTick")
  .data(scale)
  .enter()
  .append("line")
  .attr("class", "scaleTick")
  .attr("x1", "40px")
  .attr("y1", function(d)
  {
    return d + "px";
  })
  .attr("x2", "50px")
  .attr("y2", function(d)
  {
    return d + "px";
  });
  
  chart.selectAll("text.scaleText")
  .data(scale)
  .enter()
  .append("text")
  .attr("class", "scaleText")
  .attr("x", "30px")
  .attr("y", function(d)
  {
    return d + "px";
  })
  .text(
  function(d, i)
  {
    return i;
  });  
    
}


In the CSS, scaleText is defined like this.
.scaleTick, .axesVertical, .axesHorizontal
{
  stroke: rgba(100, 100, 100, 1);
  stroke-width: 1px;
}

.scaleText
{
  font: 8px verdana;
  fill: rgba(100, 100, 100, 1);
  text-anchor: end;
}


Here, the text appears. Note that your maximum value is 10!


Now we want to plot data points along the horizontal axes. To that end, we create the axes array. Then we populate it in a way similar to what we did for scale, instead using 750 and 50 as the end points in the For loop, and pxPerPoint as the decrementor.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);

  var scale = [];
  for (var i = 450; i >= 50; i -= pxPerUnit)
  {
    scale.push(i);
  }

  chart.selectAll("line.scaleTick")
  .data(scale)
  .enter()
  .append("line")
  .attr("class", "scaleTick")
  .attr("x1", "40px")
  .attr("y1", function(d)
  {
    return d + "px";
  })
  .attr("x2", "50px")
  .attr("y2", function(d)
  {
    return d + "px";
  });
  
  chart.selectAll("text.scaleText")
  .data(scale)
  .enter()
  .append("text")
  .attr("class", "scaleText")
  .attr("x", "30px")
  .attr("y", function(d)
  {
    return d + "px";
  })
  .text(
  function(d, i)
  {
    return i;
  });  

  var axes = [];
  for (var i = 750; i >= 50; i -= pxPerPoint)
  {
    axes.push(i);
  }    

}


Then we insert a bunch of 10 pixel vertical lines, styled using axesTick, into the chart. The data used is axes.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);

  var scale = [];
  for (var i = 450; i >= 50; i -= pxPerUnit)
  {
    scale.push(i);
  }

  chart.selectAll("line.scaleTick")
  .data(scale)
  .enter()
  .append("line")
  .attr("class", "scaleTick")
  .attr("x1", "40px")
  .attr("y1", function(d)
  {
    return d + "px";
  })
  .attr("x2", "50px")
  .attr("y2", function(d)
  {
    return d + "px";
  });
  
  chart.selectAll("text.scaleText")
  .data(scale)
  .enter()
  .append("text")
  .attr("class", "scaleText")
  .attr("x", "30px")
  .attr("y", function(d)
  {
    return d + "px";
  })
  .text(
  function(d, i)
  {
    return i;
  });  

  var axes = [];
  for (var i = 750; i >= 50; i -= pxPerPoint)
  {
    axes.push(i);
  }

  chart.selectAll("line.axesTick")
  .data(axes)
  .enter()
  .append("line")
  .attr("class", "axesTick")
  .attr("x1", function(d)
  {
    return d + "px";
  })
  .attr("y1", "450px")
  .attr("x2", function(d)
  {
    return d + "px";
  })
  .attr("y2", "460px");   
 
}


We'll add axesTick to this CSS specification.
.scaleTick, .axesTick, .axesVertical, .axesHorizontal
{
  stroke: rgba(100, 100, 100, 1);
  stroke-width: 1px;
}


And the horizontal axis is populated with ticks.


At the end of this method, we will run the methods drawLines() and drawBars(), passing in chart, pxPerUnit and pxPerPoint as arguments.
drawAxes: function(chart)
{
  chart
  .append("line")
  .attr("class", "axesVertical")
  .attr("x1", "50px")
  .attr("y1", "50px")
  .attr("x2", "50px")
  .attr("y2", "450px");

  chart
  .append("line")
  .attr("class", "axesHorizontal")
  .attr("x1", "50px")
  .attr("y1", "450px")
  .attr("x2", "750px")
  .attr("y2", "450px");

  var pxPerUnit = Math.floor(400 / this.maxUnits);
  var pxPerPoint = Math.floor(700 / this.dataPoints);

  var scale = [];
  for (var i = 450; i >= 50; i -= pxPerUnit)
  {
    scale.push(i);
  }

  chart.selectAll("line.scaleTick")
  .data(scale)
  .enter()
  .append("line")
  .attr("class", "scaleTick")
  .attr("x1", "40px")
  .attr("y1", function(d)
  {
    return d + "px";
  })
  .attr("x2", "50px")
  .attr("y2", function(d)
  {
    return d + "px";
  });
  
  chart.selectAll("text.scaleText")
  .data(scale)
  .enter()
  .append("text")
  .attr("class", "scaleText")
  .attr("x", "30px")
  .attr("y", function(d)
  {
    return d + "px";
  })
  .text(
  function(d, i)
  {
    return i;
  });  

  var axes = [];
  for (var i = 750; i >= 50; i -= pxPerPoint)
  {
    axes.push(i);
  }

  chart.selectAll("line.axesTick")
  .data(axes)
  .enter()
  .append("line")
  .attr("class", "axesTick")
  .attr("x1", function(d)
  {
    return d + "px";
  })
  .attr("y1", "450px")
  .attr("x2", function(d)
  {
    return d + "px";
  })
  .attr("y2", "460px");
  
  this.drawBars(chart, pxPerUnit, pxPerPoint);  
  this.drawLines(chart, pxPerUnit, pxPerPoint);  

}


Of course, we will need to create these methods.
  this.drawBars(chart, pxPerUnit, pxPerPoint);  
  this.drawLines(chart, pxPerUnit, pxPerPoint);  
},
drawLines: function(chart, pxPerUnit, pxPerPoint)
{

},
drawBars: function(chart, pxPerUnit, pxPerPoint)
{

}


Let's begin with drawLines(). We want to use the line portion of the combo chart to represent the smoking data. Thus, we declare cigData and set it to the value of the cigData array.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;
},


Now, in chart, we want to append line tags. cigData is the dataset we will use. The CSS class used for this is lineChart.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart");

},


In the CSS, lineChart is a two pixel red line.
.scaleText
{
  font: 8px verdana;
  fill: rgba(100, 100, 100, 1);
  text-anchor: end;
}

.lineChart
{
  stroke: rgba(255, 0, 0, 1);
  stroke-width: 2px;
}


We set x1 like this. First, we define the variable val which is the index of the dataset, i. If it is currently not the first element in the dataset (i.e, i is greater than 0), then we decrement val.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart")
  .attr("x1", function(d, i)
  {
   var val = i;
  
   if (i > 0)
   {
    val = val - 1;
   }  
  })
;
},


And then we set x1 to val multiplied by pxPerPoint, plus 50 for the buffer. In effect, if it's the first element in the dataset, x1 does pretty much nothing because it's at 0. If not, it uses the previous position in the dataset.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart")
  .attr("x1", function(d, i)
  {
   var val = i;
  
   if (i > 0)
   {
    val = val - 1;
   }
  
   return ((val * pxPerPoint) + 50) + "px";
  });
},


x2, of course, is the current index, i, multipled by pxPerPoint and plus 50.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart")
  .attr("x1", function(d, i)
  {
   var val = i;
  
   if (i > 0)
   {
    val = val - 1;
   }
  
   return ((val * pxPerPoint) + 50) + "px";
  })
  .attr("x2", function(d, i)
  {
   return ((i * pxPerPoint) + 50) + "px";
  })
;
},


For y1, we do something similar to what we did for x1. Except that we initially set val to the current value of the dataset instead of the index. And if it's not the first element in the dataset, we set val to the previous value of cigData.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart")
  .attr("x1", function(d, i)
  {
   var val = i;
  
   if (i > 0)
   {
    val = val - 1;
   }
  
   return ((val * pxPerPoint) + 50) + "px";
  })
  .attr("x2", function(d, i)
  {
   return ((i * pxPerPoint) + 50) + "px";
  })
  .attr("y1", function(d, i)
  {
   var val = d;
  
   if (i > 0)
   {
    val = cigData[i - 1];
   }  
  })
;
},


And then we set y1 by taking 450, which is position 0 on the vertical axis, and subtracting the product of val and pxPerUnit from it.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart")
  .attr("x1", function(d, i)
  {
   var val = i;
  
   if (i > 0)
   {
    val = val - 1;
   }
  
   return ((val * pxPerPoint) + 50) + "px";
  })
  .attr("x2", function(d, i)
  {
   return ((i * pxPerPoint) + 50) + "px";
  })
  .attr("y1", function(d, i)
  {
   var val = d;
  
   if (i > 0)
   {
    val = cigData[i - 1];
   }
  
   return (450 - (val * pxPerUnit)) + "px";
  });
},


And we set y2 similarly, except we just use d in a more straightforward way.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
  var cigData = this.cigData;

  chart.selectAll("line.lineChart")
  .data(cigData)
  .enter()
  .append("line")
  .attr("class", "lineChart")
  .attr("x1", function(d, i)
  {
   var val = i;
  
   if (i > 0)
   {
    val = val - 1;
   }
  
   return ((val * pxPerPoint) + 50) + "px";
  })
  .attr("x2", function(d, i)
  {
   return ((i * pxPerPoint) + 50) + "px";
  })
  .attr("y1", function(d, i)
  {
   var val = d;
  
   if (i > 0)
   {
    val = cigData[i - 1];
   }
  
   return (450 - (val * pxPerUnit)) + "px";
  })
  .attr("y2", function(d)
  {
   return (450 - (d * pxPerUnit)) + "px";
  })
;
},


You see that red line? That shows me starting the programme at 10 cigarettes a day, then moving my way down to 5 gradually over the next few months, and finally dropping to 0.


The next part, drawing the bar chart portion of the combo chart, is simple by comparison. We begin by declaring chinupData and setting it to the value of the chinupData array.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
  var chinupData = this.chinupData;
}


In chart, we append rect tags that are styled using the CSS class barChart. The data used for this is chinupData.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
  var chinupData = this.chinupData;

  chart.selectAll("rect.barChart")
  .data(chinupData)
  .enter()
  .append("rect")
  .attr("class", "barChart");


In the CSS, the barChart CSS class has an orange background.
.lineChart
{
  stroke: rgba(255, 0, 0, 1);
  stroke-width: 2px;
}

.barChart
{
  fill: rgba(255, 200, 100, 1);
  stroke-width: 0px;
}


Let's do the easy part first. Every bar has the same width - pxPerPoint pixels.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
  var chinupData = this.chinupData;

  chart.selectAll("rect.barChart")
  .data(chinupData)
  .enter()
  .append("rect")
  .attr("class", "barChart")
  .attr("width", pxPerPoint + "px");
}


The height depends on the value of the current element in chinupData, d. We multiply d by pxPerUnit to get the height.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
  var chinupData = this.chinupData;

  chart.selectAll("rect.barChart")
  .data(chinupData)
  .enter()
  .append("rect")
  .attr("class", "barChart")
  .attr("width", pxPerPoint + "px")
  .attr("height", function(d)
  {
    return (d * pxPerUnit) + "px";
  })
;
}


The x attribute depends on the index, i, of the current element. We multiply it by pxPerPoint and add 50 for the horizontal buffer.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
  var chinupData = this.chinupData;

  chart.selectAll("rect.barChart")
  .data(chinupData)
  .enter()
  .append("rect")
  .attr("class", "barChart")
  .attr("x", function(d, i)
  {
    return ((i * pxPerPoint) + 50) + "px";
  })

  .attr("width", pxPerPoint + "px")
  .attr("height", function(d)
  {
    return (d * pxPerUnit) + "px";
  });
}


For y, we take 450 (which is position 0 on the vertical axis) and subtract from it the product of d and pxPerUnit. In essence, we subtract the height of the bar from 450 to get the starting y-position of the rect tag.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
  var chinupData = this.chinupData;

  chart.selectAll("rect.barChart")
  .data(chinupData)
  .enter()
  .append("rect")
  .attr("class", "barChart")
  .attr("x", function(d, i)
  {
    return ((i * pxPerPoint) + 50) + "px";
  })
  .attr("y", function(d)
  {
    return (450 - (d * pxPerUnit)) + "px";
  })

  .attr("width", pxPerPoint + "px")
  .attr("height", function(d)
  {
    return (d * pxPerUnit) + "px";
  });
}


And there's the orange bar chart. You can see where I started off at 1 chinup, then shot up to 3 within a week, and how I gradually worked my way up from there.


There, all done!

This was simple in comparison to all the stuff we've gone through before with D3. That is a deliberate choice on my part - to give you the Combo Chart without throwing too much stuff in.

Also - and yes, I just wanna brag here - I'm three days away from making it an entire year without a cigarette. Go, me!

Cig-nificantly yours,
T___T

Thursday, 12 June 2025

The Sheepshank Analogy

In seamanship, there are various shortening knots, in what I like to call the "shank family". These are used to take up slack in ropes, and only hold up under a certain amount of tension (too little or too much tension, and they collapse). Here are some of them...

The Catshank.




The Dogshank.



And arguably the most famous and basic, the Sheepshank, upon which the previous two were based. I know all this sounds deliciously violent (shanking various animals, really?!) but bear with me, a point is being made here.

The Sheepshank, loosely tied for illustration.

When the knot is tied, the rope is shortened. Once untied, we see that the rope is really a lot longer than we initially gave it credit for. And that leads us to...

Code editors!

Let's just use Sublime Text as an example. Sometimes the code can get really long, but Sublime Text (indeed, many other Code Editors) has this feature where you can visually compress the code.

A HTML example.

Before.

After.


A JavaScript example.

Before.

After.


Kind of like a Sheepshank! The code is still there, but the presentation hides it. And then it can be revealed again, easily.

Final words

I know this doesn't seem like much of an analogy. As analogies go, admittedly, it's pretty tame. But honestly, that was exactly what I was thinking of one day when I was expanding some code in Sublime Text - wow, this is kind of like untying a Sheepshank!

Shanks for reading,
T___T

Monday, 9 June 2025

The Dark Years of COVID-19: A Software Developer's Perspective (Part 3/3)

The latter half of 2022 was when the masks came off. In August, it was announced that masking up was no longer mandatory except in medical facilities and food preparation, and on public transport.

It seemed like a small thing, but wow, the sensation of wind on my face after years of this shit was... incredible.

By February of 2024, masks were no longer required on public transport. There was a real sense that this was the last remnants of COVID-19 restrictions being lifted.

Masking no longer
required on public
transport.

Cinemas were allowed to open to full capacity. Swimming complexes and gyms no longer required users to go through a daily booking process, with penalties for no-shows. People tossed their contact tracing tokens. I kept mine around because it was cute.

Large gatherings were allowed again. With that, employers began pushing for a return to office. People started travelling again - ticket prices stopped being ridiculously expensive.

It felt like Singapore, after years of slumber, was starting to wake up once more.

2024 to now

COVID-19 didn't go away. However, it rapidly began to feel like it had become yesterday's news. COVID-19 was no longer the extinction event it had once threatened to be.

As a nation, we took cautious steps on the road to recovery. Some things were never quite the same again. Some businesses folded even after restrictions were lifted. Professionals pivoted to different careers. There was a limit, after all, to how much Government intervention could accomplish.

Mental health and depression.

And for some reason - perhaps this was just my imagination - the issue of mental health seemed to come to the forefront of societal consciousness.  Either the causes of depression predated COVID-19 and this was merely exacerbated due to social distancing and lockdowns, or the long-term effects of having contracted COVID-19 had some effect on brain chemistry.
Because it wasn't just the medical effects of COVID-19. The damage dealt to the economy was not inconsiderable.

Even now, big tech is in the process of laying off all the tech people they snapped up during the dark years of COVID-19. That bubble has burst. It seems especially bad in Silicon Valley, though Singapore is affected as well due to the simple fact that big tech also has offices in Singapore.

As a software developer, I am just profoundly glad that I never hopped on to that gravy train. Sometimes being completely mediocre is a blessing.

Final thoughts

I've said earlier that Singaporeans generally seemed to be becoming alarmingly stupid during the pandemic. Upon further reflection, the truth may have been far worse.

You see, Singaporeans are generally well-educated. We can speak and even write in a variety of languages. We are capable of rational thought. Therefore I find it hard to believe that the average Singaporean would have difficulty understanding the concepts I outlined earlier. No, what Singaporeans suffer from isn't stupidity; rather, it is Main Character Syndrome. The inability to grasp that there are things bigger than them, and that not everything is about them. The inability to get over themselves.

However, for good or ill, this was what the Singapore Government had to work with. It wasn't all bad. Some of us stepped up - some in small ways like myself, some in more substantive ways. We responded as a society, and while there might have been plenty of grumbling as we did it, it is part and parcel of the Singapore DNA. These were heartening signs in what looked like a sea of negativity. On my part, the crists had awakened some kind of social conscience within myself. I wasn't exactly rushing out to the frontlines to offer my aid. I didn't become a Safe Distancing Ambassador or help deliver supplies. I didn't help swab thousands of people a day to test for COVID-19. And I certainly did not participate in helping to write new and interesting software that could help Singapore tide over this crisis. That all said, I gained a new appreciation for how well-run Singapore generally is, and did my small part as a citizen.
Small corner stores.

I spared a few thoughts for my immediate community rather than focus on my own survival. Any money that the Singapore Government disbursed to citizens as aid during this period, I promptly donated to charity. I took pains to patronize small stores and hawkers, as these were the most vulnerable to changes in the economy.
And, despite the fact that I didn't know for sure that vaccination jabs were absolutely safe, I took them.

What does that have to do with a social conscience? You see, Singapore is in a unique situation. The size of this country, with its lack of manpower and natural resources, means that we must open our borders to foreign investment in order to survive. Had we insisted on staying closed, Singapore would have sunk just as surely as if all of us had been overrun by the coronavirus. Sure, we would have sunk slower, but we would have sunk nonetheless. Singapore required at least eighty percent of her citizens to be vaccinated before we could open our borders. There was a chance I could die if I accepted the jab. I took that chance. I will never be great. I'll never be a Bill Gates or a Steve Jobs. I will never cure cancer, create art that will transcend generations, or otherwise achieve anything monumental. But whatever small thing I could do for my country, I did. The jab was never about saving my statistically insignificant life. At the risk of sounding cliche, it was for the greater good. I do value my life... I'm just not vain enough to think it's more important than the millions of livelihoods at stake.

Epilogue

It's 2025. COVID-19 is a distant memory, but not so distant that the turmoil has not left its mark. Now and again, we see some remnant of the measures that were in place during the pandemic. The posters. The tracing tokens. Hand sanitizer. Abandoned gantries.

Remnants of those
dark years.

It's recent enough that any talk of a possible pandemic is instantly compared to COVID-19. Not the Spanish Flu. Not the Red Death, or the Bubonic Plague.

The world seems to have recovered, but the scars run deep.

COVID-19 was nothing to sneeze at, y'all!
T___T