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>
With simple CSS app.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:
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.
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")
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()
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:
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:
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:
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.
Top comments (0)