Open Source Adventures: Episode 36: Using D3 to figure out when Russia will lose its last armored vehicle
Tags: d3, svelte, javascript
Published: 2022-04-10 16:49:57

We can extend the tank losses app to armored vehicles and artillery.

This could be done with just copy and paste, but I wanted to refactor the app a bit, to reduce such repetitive elements.

### `src/App.svelte`

Due to the way equipment is categorized, I'm merging regular artillery with MRL.

```html
<script>
import * as d3 from "d3"
import TankLosses from "./TankLosses.svelte"
import ArmoredLosses from "./ArmoredLosses.svelte"
import ArtilleryLosses from "./ArtilleryLosses.svelte"

let parseRow = (row) => ({
  date: new Date(row.date),
  tank: +row.tank,
  apc: +row.APC,
  art: +row["field artillery"] + +row["MRL"],
})

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

let dataPromise = loadData()
</script>

{#await dataPromise then data}
  <TankLosses {data} />
  <ArmoredLosses {data} />
  <ArtilleryLosses {data} />
{/await}

<style>
:global(body) {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
</style>
```

### `src/TankLosses.svelte`

I'll only show the tank side, as the other two are too similar. except there's no dedicated artillery storage.

```html
<script>
import TankForm from "./TankForm.svelte"
import LossesGraph from "./LossesGraph.svelte"

export let data

let lossData = data.map(({date, tank}) => ({date, unit: tank}))

// put some dummy data to avoid issues with initialization order
let adjustmentLoss = 0, futureIntensity = 100, total = 0
</script>

<h1>Russian Tank Losses</h1>
<LossesGraph {lossData} {adjustmentLoss} {futureIntensity} {total} label="tank" />
<TankForm bind:adjustmentLoss bind:futureIntensity bind:total />
```

### `src/TankForm.svelte`

I moved the slider logic to `Slider` component. They're formatted differently (`10`, `10%`, `+10%`), so we're passing `format` function to the component.

```html
<script>
import * as d3 from "d3"
import Slider from "./Slider.svelte"

export let adjustmentLoss = 0
export let futureIntensity = 100
let active = 3417
let storage = 10200
let storageGood = 10

export let total

$: total = Math.round(active + storage * storageGood / 100.0)

</script>

<form>
  <Slider label="Adjustment for losses data" min={-30} max={50} bind:value={adjustmentLoss} format={(v) => d3.format("+d")(v) + "%"} />
  <Slider label="Predicted future war intensity" min={-50} max={200} bind:value={futureIntensity} format={(v) => `${v}%`} />
  <Slider label="Russian tanks at start of war" min={2500} max={3500} bind:value={active} format={(v) => v} />
  <Slider label="Russian tanks in storage" min={8000} max={12000} bind:value={storage} format={(v) => v} />
  <Slider label="Usable tanks in storage" min={0} max={100} bind:value={storageGood} format={(v) => `${v}%`} />

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

<style>
form {
  display: grid;
  grid-template-columns: auto auto auto;
}
form > div {
  display: contents;
}
</style>
```

### `src/Slider.svelte`

The `label for` problem doesn't have a good solution. For this I'm just using randomly generated IDs.

```html
<script>
export let label, min, max, value, format
let id = Math.random().toString(36).slice(2)
</script>

<label for={id}>{label}:</label>
<input type="range" {min} {max} bind:value id={id} />
<span>{format(value)}</span>
```

### `src/LossesGraph.svelte`

The graph is the same for all kinds of losses, so I put all the calculations and display logic here:

```html
<script>
import * as d3 from "d3"
import Graph from "./Graph.svelte"

export let lossData, total, adjustmentLoss, futureIntensity, label

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

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

$: adjustedData = adjust(lossData, adjustmentLoss)
$: alreadyDestroyed = d3.max(adjustedData, d => d.unit)
$: unitsMax = Math.max(alreadyDestroyed, total)

$: currentDestroyRate = alreadyDestroyed / (maxDate - minDate)
$: futureDestroyRate = (currentDestroyRate * futureIntensity / 100.0)
$: unitsTodo = total - alreadyDestroyed
$: lastDestroyedDate = new Date(+maxDate + (unitsTodo / futureDestroyRate))

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

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

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

$: trendPathData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.unit))
  ([adjustedData[0], adjustedData[adjustedData.length - 1], {unit: total, date: lastDestroyedDate}])

$: totalPathData = d3.line()
  .x(xScale)
  .y(yScale(unitsMax))
  ([minDate, lastDestroyedDate])

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

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

<Graph {pathData} {trendPathData} {totalPathData} {xAxis} {yAxis}/>
<div>Russia will lose its last {label} on {d3.timeFormat("%e %b %Y")(lastDestroyedDate)}</div>
```

### `src/Graph.svelte`

This component just gets the calculated data and paths and plots them:

```html
<script>
import Axis from "./Axis.svelte"
export let pathData, trendPathData, totalPathData, xAxis, yAxis
</script>

<svg viewBox="0 0 800 600">
  <g class="graph">
    <path class="data" d={pathData}/>
    <path class="trendline" d={trendPathData}/>
    <path class="total" d={totalPathData}/>
  </g>
  <g class="x-axis"><Axis axis={xAxis}/></g>
  <g class="y-axis"><Axis axis={yAxis}/></g>
</svg>

<style>
svg {
  width: 800px;
  max-width: 100vw;
  display: block;
}
.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.total {
  stroke: blue;
  stroke-width: 1.5;
}
.x-axis {
  transform: translate(50px, 520px);
}
.y-axis {
  transform: translate(50px, 20px);
}
</style>
```

### `src/Axis.svelte`

It's a small wrapper to hand over control over `<g>` from Svelte to D3.

```html
<script>
import * as d3 from "d3"
export let axis

let axisNode

$: {
  d3.select(axisNode).selectAll("*").remove()
  d3.select(axisNode).call(axis)
}
</script>

<g bind:this={axisNode}></g>
```

### Story so far

[All the code is on GitHub](https://github.com/taw/open-source-adventures).

I deployed this on GitHub Pages, [you can see it here](https://taw.github.io/open-source-adventures/episode-36/).

### Coming next

That's enough for now. For the next episode we'll try something completely different.