DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 29: Using D3 with old school tooling to visualize Russian Tank Losses

There's still a lot of environments where you don't have node, don't have fancy build system, all you can use is just some static HTML and JavaScript.

D3 still works great in such environments!

How to get D3

So the first step is to grab D3 JavaScript file, from a service like jsdelivr - here's one for version 7 the latest. We can either link to it, or save that URL locally as d3.js.

Create a simple static HTML page

We can do it very old style:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="app.css">
  </head>
  <body>
    <script src="./d3.js"></script>
    <script src="./app.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

With simple CSS app.css:

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
Enter fullscreen mode Exit fullscreen mode

And our simple app.js just to verify that D3 worked:

d3.select("body").append("h1").text("Hello, World!")
Enter fullscreen mode Exit fullscreen mode

Tank Losses Data

OK, now that we have it setup, let's do something interesting - visualize Russian tank losses in Ukraine. We can get CSV from Kaggle.

These are estimates coming from Ukraine government, and as a general rule, everybody in history overestimated enemy losses.

Data for which we have photographic evidence is lower. For tanks Oryx has 425, while Ukraine claims 676. These numbers are actually very close, as Oryx gets pictures of destroyed Russian equipment faster than they can process it, and obviously not every destroyed tank will be photographed, and even if it get photographed, there's a delay between that and Oryx getting and processing those pictures. If the fighting stopped now, Oryx count would keep ticking.

There are two files - russia_losses_equipment.csv and russia_losses_personnel.csv, and we're only interested in two columns in the first file (date and tank).

Load data

D3 can load data from CSV files. Unfortunately we run into first small issue - if we open index.html as local file, browser won't let us load data from other files. "same origin policy" does not mean "files on the same computer".

So we need to start a local server with ruby -run -e httpd -- . -p 8000 or Python equivalent.

After that we run into second problem. d3.csv is async, so we'd like to just do this:

let data = await d3.csv("./russia_losses_equipment.csv")
Enter fullscreen mode Exit fullscreen mode

But browsers don't support top-level await yet. So we need to put it all inside as async function.

As CSV doesn't distinguish numbers from strings, so we can pass a functtion to convert it:

let parseRow = ({date,tank}) => ({date: new Date(date), tank: +tank})

let main = async () => {
  let data = await d3.csv("./russia_losses_equipment.csv", parseRow)
  console.log(data)
}

main()
Enter fullscreen mode Exit fullscreen mode

Scales

The first part of visualizing data is getting scale right. We can use d3.extent data:

> d3.extent(data, row => row.tank)
[80, 676]
> d3.extent(data, row => row.date)
['2022-02-25', '2022-04-05']
Enter fullscreen mode Exit fullscreen mode

But hold on, starting losses at 80 is silly. It should start at 0. And we probably should add extra 2022-02-24, 0 entry for a cleaner graph.

With this in mind we can setup some scales to translate dates and tank loss counts to pixel coordinates:

let parseRow = ({date,tank}) => ({date: new Date(date), tank: +tank})

let main = async () => {
  let data = await d3.csv("./russia_losses_equipment.csv", parseRow)
  data.unshift({date: new Date("2022-02-24"), tank: 0})

  let xScale = d3.scaleTime()
    .domain(d3.extent(data, d => d.date))
    .range([0, 600])

  let yScale = d3.scaleLinear()
    .domain(d3.extent(data, d => d.tank))
    .range([400, 0])
}

main()
Enter fullscreen mode Exit fullscreen mode

X is direct it goes right (towards higher X). Y scale is inverted as it goes up (towards lower Y). Y scale is also a time scale.

Display data and axes

And now we can display the whole data:

let parseRow = ({date,tank}) => ({date: new Date(date), tank: +tank})

let main = async () => {
  let data = await d3.csv("./russia_losses_equipment.csv", parseRow)
  data.unshift({date: new Date("2022-02-24"), tank: 0})

  let xScale = d3.scaleTime()
    .domain(d3.extent(data, d => d.date))
    .range([0, 600])

  let yScale = d3.scaleLinear()
    .domain(d3.extent(data, d => d.tank))
    .range([400, 0])

  let svg = d3.select("body")
    .append("svg")
      .attr("width", 800)
      .attr("height", 600)
    .append("g")
      .attr("transform", "translate(100, 100)")

  svg.append("g")
    .call(d3.axisLeft(yScale))

  svg.append("g")
    .attr("transform", "translate(0, 400)")
    .call(d3.axisBottom(xScale))

  svg.append("path")
    .datum(data)
    .attr("fill", "none")
    .attr("stroke", "red")
    .attr("stroke-width", 1.5)
    .attr("d", d3.line()
      .x(d => xScale(d.date))
      .y(d => yScale(d.tank)))
}

main()
Enter fullscreen mode Exit fullscreen mode

D3 by default generates SVG elements at <0,0> coordinates, so it's easiest to shift it to the proper place with g with appropriate transform: translate(x,y) attribute.

And that's what it looks like:

Episode 29 Screenshot

Story so far

I deployed this on GitHub Pages, so you can see it here:

Coming next

In the next episode, we'll port this app to more modern tooling. Then we'll see how it works with some modern framework and add some fancy features.

Oldest comments (0)