Open Source Adventures: Episode 51: Polishing Russian Losses App

There's been some feedback to my app, mainly:

  • it's unclear what "Projection basis in days" means, I replaced it with "Extrapolate from last N days"
  • it's unclear what personnel losses graph means, there are 3 colors with no explanation (KIA, WIA, total)

I also want to do some more things:

  • add some footer with sources information
  • add end-of-the-year expected personnel losses summary
  • slightly adjust min/max the sliders can go (defaults can stay the same)

So let's get going!

Footer.svelte

It's just some static HTML:

<footer>
  Initial forces from <a href="https://www.iiss.org/publications/the-military-balance">IISS Military Balance 2022</a>.
  Daily losses from <a href="https://www.kaggle.com/datasets/piterfm/2022-ukraine-russian-war">Ukraine Armed Forces.</a>
  <a href="https://github.com/taw/open-source-adventures">Source code available on GitHub</a>.
</footer>

<style>
  footer {
    margin-top: 32px;
    color: #444;
  }
</style>

SoldierLossesGraph.svelte

This component is really getting quite complicated, and the end of year totals have fairly complicated logic to avoid surprising rounding.

<script>
import * as d3 from "d3"
import SoldierGraph from "./SoldierGraph.svelte"
import { lossAdjustment, projectionBasis, wiaToKia, kiaRegular, futureIntensity } from "./stores"

export let lossData

let adjustRow = ({date, unit}, totalLossAdjustment, wiaToKia) => {
  let kia = Math.round(unit * totalLossAdjustment)
  let wia = Math.round(kia * wiaToKia / 100)
  let total = kia + wia
  return {date, kia, wia, total}
}
let adjust = (data, totalLossAdjustment, wiaToKia) => data.map(row => adjustRow(row, totalLossAdjustment, wiaToKia))

let at = (array, idx) => ((idx < 0) ? array[array.length + idx] : array[idx])

let round100 = (x) => Math.round(x / 100) * 100

let formatEoy = (x) => d3.format(".1f")(x / 1000.0) + "k"

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

$: adjustedData = adjust(lossData, ($kiaRegular/100) * (1 + $lossAdjustment / 100.0), $wiaToKia)
$: totalSoFar = d3.max(adjustedData, d => d.total)

$: timeInProjection = at(adjustedData, -$projectionBasis-1).date - at(adjustedData, -1).date
$: kiaInProjection = at(adjustedData, -$projectionBasis-1).kia - at(adjustedData, -1).kia
$: wiaInProjection = at(adjustedData, -$projectionBasis-1).wia - at(adjustedData, -1).wia
$: currentKiaRate = kiaInProjection / timeInProjection
$: currentWiaRate = wiaInProjection / timeInProjection

$: futureKiaRate = (currentKiaRate * $futureIntensity / 100.0)
$: futureWiaRate = (currentWiaRate * $futureIntensity / 100.0)
$: futureTotalRate = futureKiaRate + futureWiaRate

// Just hardcode as there's no obvious "finish date"
$: lastDate = new Date("2023-01-01")
$: graphTime = lastDate - maxDate

// How many KIA+WIA by lastDate
$: unitsMax = Math.round(graphTime * futureTotalRate) + totalSoFar

$: kiaSoFar = d3.max(adjustedData, d => d.kia)
$: wiaSoFar = d3.max(adjustedData, d => d.wia)
$: eoyKia = round100(graphTime * futureKiaRate + kiaSoFar)
$: eoyWia = round100(graphTime * futureWiaRate + wiaSoFar)
$: eoyTotal = eoyKia + eoyWia

$: eoyIKia = round100(eoyKia / $kiaRegular * (100 - $kiaRegular))
$: eoyIWia = round100(eoyWia / $kiaRegular * (100 - $kiaRegular))
$: eoyITotal = eoyIKia + eoyIWia

$: trendData = [
  adjustedData[0],
  at(adjustedData, -1),
  {
    date: lastDate,
    kia: Math.round(graphTime * futureKiaRate) + d3.max(adjustedData, d => d.kia),
    wia: Math.round(graphTime * futureWiaRate) + d3.max(adjustedData, d => d.wia),
    total: Math.round(graphTime * futureTotalRate) + d3.max(adjustedData, d => d.total),
  },
]

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

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

$: yAxis = d3
  .axisLeft()
  .scale(yScale)

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

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

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

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

$: kiaTrendData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.kia))
  (trendData)

$: wiaTrendData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.wia))
  (trendData)

$: totalTrendData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.total))
  (trendData)
</script>

<SoldierGraph {xAxis} {yAxis} {kiaData} {wiaData} {totalData} {kiaTrendData} {wiaTrendData} {totalTrendData} />
<div>
  <span class="box kia"></span> Killed
  <span class="box wia"></span> Wounded
  <span class="box total"></span> Total
</div>
<div>By end of the year, Russia will lose {formatEoy(eoyTotal)} ({formatEoy(eoyKia)} killed, {formatEoy(eoyWia)} wounded) regular soldiers.</div>
<div>As well as {formatEoy(eoyITotal)} ({formatEoy(eoyIKia)} killed, {formatEoy(eoyIWia)} wounded) irregulars (separatists, PMCs etc.)</div>

<style>
  .box {
    display: inline-block;
    height: 10px;
    width: 10px;
    border: 1px solid black;
  }
  .box.kia {
    background-color: red;
  }
  .box.wia {
    background-color: green;
  }
  .box.total {
    background-color: blue;
  }
</style>

This generates summary like:

By end of the year, Russia will lose 208.6k (59.6k killed, 149.0k wounded) regular soldiers. As well as 52.2k (14.9k killed, 37.3k wounded) irregulars (separatists, PMCs etc.)

Story so far

All the code is on GitHub.

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