Open Source Adventures: Episode 29: Using D3 with old school tooling to visualize Russian Tank Losses
Tags: javascript, d3
Published: 2022-04-05 16:35:18

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](https://cdn.jsdelivr.net/npm/d3@7). 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:

```html
<!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>
```

With simple CSS `app.css`:

```css
body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
```

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

```javascript
d3.select("body").append("h1").text("Hello, World!")
```

### 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](https://www.kaggle.com/datasets/piterfm/2022-ukraine-russian-war?resource=download).

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](https://www.oryxspioenkop.com/2022/02/attack-on-europe-documenting-equipment.html). 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:

```javascript
let data = await d3.csv("./russia_losses_equipment.csv")
```

[But browsers don't support top-level `await` yet](https://github.com/tc39/proposal-top-level-await). 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()
```

### 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']
```

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:

```javascript
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()
```

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:

```javascript
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()
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8jyo52eiepdayrchjjsg.png)

### Story so far

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

* [Hello, World](https://taw.github.io/open-source-adventures/episode-29/hello/)
* [Tanks](https://taw.github.io/open-source-adventures/episode-29/tanks/)

### 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.