DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 33: Using D3 to figure out when Russia will lose its last tanks

OK, let's get to the main point of the app - figuring out when Russia will run out of tanks.

Data sources on Russian losses

We can take either Ukraine Armed Forces data, or Oryx data, and add adjustments to it. The numbers are actually pretty similar, so for now I'll just use Ukraine Armed Forces data.

There's adjustment slider for what you think actual losses are. For this I'll set it to -30% to +50% range. -30% is lower than it could reasonably go, as we have photographic evidence for 70% of what Ukraine says it destroyed.

Oryx is open that they have big unprocessed backlog of Russian loss pictures. Based on their tweets, it sounds like backlog alone is about 10% of total losses so far. Just that reduces the difference between Oryx and Ukrainian data from -30% to -23% below official Ukrainian data.

If we assume that 77% of losses will have photographic evidence already sent to Oryx - 23% being some combination of not photographed, not photographed yet, photographed but not yet made it to Oryx, or destroyed so throughoutly that it's not possible to identify what's on the photo - then Oryx data would be identical to official Ukrainian data.

It's a natural assumption that everyone overestimates enemy's losses, so it would be natural to start with big discount, but I find adjustment values lower than -10% hard to justify, just based on Oryx data. Adjustment over +0% without some additional evidence would also be difficult to justify. But I'll leave the default at +0%.

Ukrainian and Oryx data for other kinds of ground vehicles is also in rough agreement.

There is no such agreement when it comes to air losses, and Ukrainian numbers are many times higher than Oryx numbers. Shot down planes and helicopters would leave a lot less evidence on the ground than tanks, so Oryx is definitely undercounting them, but even assuming that Ukrainian numbers look much too high.

As for casualties, we don't really have any independent information.

Just in case you're wondering, Russian data is all pure lies. The people in Russia who write that propaganda likely don't even have access to any real data and just make it all up. There is zero symmetry here.

Anyway, for now this graph is only trying to answer questions about Russian tank losses, so let's get to that!

Data sources on Russian tanks inventory

The other question is how many tanks Russia has. According to sources like Wikipedia there's about 2800 tanks in active service, and about 10000 in storage.

It's unclear how many tanks "in storage" are even possible to get running. Most will be either completely rusted through or only good for spare parts. Slider goes from 0% to 100%, with default of 10%.

Predicting the future

The last bit of information is how fast you expect Russia to be losing tanks in the future compared with what happened so far. The slider goes from 50% to 200%, with default of 100%.

So let's get going!

src/Form.svelte

Form component is 5 sliders with some labels and text. We don't export active / storage / storage good, just their total, as we don't really need that breakdown.

<script>
import * as d3 from "d3"

export let adjustmentLoss = 0
export let futureIntensity = 100
let activeTanks = 2800
let storageTanks = 10000
let storageGood = 10

export let totalTanks

$: totalTanks = Math.round(activeTanks + storageTanks * storageGood / 100.0)

</script>

<form>
  <div>
    <label for="loss">Adjustment for losses data:</label>
    <input type="range" min="-30" max="50" bind:value={adjustmentLoss} id="loss" />
    <span>{d3.format("+d")(adjustmentLoss)}%</span>
  </div>

  <div>
    <label for="intensity">Predicted future war intensity</label>
    <input type="range" min="50" max="200" bind:value={futureIntensity} id="intensity" />
    <span>{futureIntensity}%</span>
  </div>

  <div>
    <label for="active">Russian tanks at start of war</label>
    <input type="range" min="2500" max="3000" bind:value={activeTanks} id="active" />
    <span>{activeTanks}</span>
  </div>

  <div>
    <label for="storage">Russian tanks in storage</label>
    <input type="range" min="8000" max="12000" bind:value={storageTanks} id="active" />
    <span>{storageTanks}</span>
  </div>

  <div>
    <label for="good">Usable tanks in storage</label>
    <input type="range" min="0" max="100" bind:value={storageGood} id="good" />
    <span>{storageGood}%</span>
  </div>

  <div>
    <span>Total usable tanks</span>
    <span></span>
    <span>{totalTanks}</span>
  </div>
</form>

<style>
form {
  display: grid;
  grid-template-columns: auto auto auto;
}
form > div {
  display: contents;
}
</style>
Enter fullscreen mode Exit fullscreen mode

src/Graph.svelte

There's a lot of reactive $: annotations here. Svelte figures out what depends on what and does the correct updates automatically.

This is exactly the kind of app Svelte is great for. React version would require a lot of reactivity annotations, and callbacks from Form to Graph component.

This component is a bit big, and calculations and graph display could be split into separate components.

<script>
import * as d3 from "d3"
import Axis from "./Axis.svelte"
import Form from "./Form.svelte"

export let data

let adjust = (data, adjustmentLoss) => data.map(({date, tank}) => ({date, tank: Math.round(tank * (1 + adjustmentLoss/100))}))

// put some dummy data to avoid issues with initialization order
let adjustmentLoss = 0, futureIntensity = 100, totalTanks = 0

let [minDate, maxDate] = d3.extent(data, d => d.date)

$: adjustedData = adjust(data, adjustmentLoss)
$: alreadyDestroyedTanks = d3.max(adjustedData, d => d.tank)
$: tanksMax = Math.max(alreadyDestroyedTanks, totalTanks)

$: currentTankRate = alreadyDestroyedTanks / (maxDate - minDate)
$: futureTankRate = (currentTankRate * futureIntensity / 100.0)
$: tanksTodo = totalTanks - alreadyDestroyedTanks
$: lastTankDate = new Date(+maxDate + (tanksTodo / futureTankRate))

$: xScale = d3.scaleTime()
  .domain([minDate, lastTankDate])
  .range([0, 700])

$: yScale = d3.scaleLinear()
  .domain([0, tanksMax])
  .nice()
  .range([500, 0])

$: pathData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.tank))
  (adjustedData)

$: trendPathData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.tank))
  ([adjustedData[0], adjustedData.at(-1), {tank: totalTanks, date: lastTankDate}])

$: tankTotalPathData = d3.line()
  .x(xScale)
  .y(yScale(tanksMax))
  ([minDate, lastTankDate])

$: xAxis = d3.axisBottom()
  .scale(xScale)
  .tickFormat(d3.timeFormat("%e %b %Y"))

$: yAxis = d3
  .axisLeft()
  .scale(yScale)
</script>

<h1>Russian Tank Losses</h1>
<svg>
  <g class="graph">
    <path class="data" d={pathData}/>
    <path class="trendline" d={trendPathData}/>
    <path class="tanktotal" d={tankTotalPathData}/>
  </g>
  <g class="x-axis"><Axis axis={xAxis}/></g>
  <g class="y-axis"><Axis axis={yAxis}/></g>
</svg>

<Form bind:adjustmentLoss bind:futureIntensity bind:totalTanks />
<div>Russia will lose its last tank on {d3.timeFormat("%e %b %Y")(lastTankDate)}</div>

<style>
svg {
  height: 600px;
  width: 800px;
}
.graph {
  transform: translate(50px, 20px);
}
path {
  fill: none;
}
path.data {
  stroke: red;
  stroke-width: 1.5;
}
path.trendline {
  stroke: red;
  stroke-width: 1.5;
  stroke-dasharray: 3px;
}
path.tanktotal {
  stroke: blue;
  stroke-width: 1.5;
}
.x-axis {
  transform: translate(50px, 520px);
}
.y-axis {
  transform: translate(50px, 20px);
}
</style>
Enter fullscreen mode Exit fullscreen mode

The result

With default settings, Russia will lose its last tank on 9 Oct 2022. Of course the point of this app is to check how changing assumptions changes the result.

After that date Russian occupiers will use machine guns mounted on Ural trucks or something.

Story so far

All the code is on GitHub.

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

Coming next

In the next episode, I'll try to do a few more things with the app.

Top comments (0)