DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 41: Projection Basis Slider for Russian Losses App

When I started the Russian Losses app, it wasn't clear if there's any specific trend, so I just used simple linear fit.

But right now it seems that the fit will be more complex, with about three phases, roughly corresponding to months:

  • February - rapid Russian advance everywhere, and massive losess everywhere, hundreds of vehicles abandoned in the mud on the way, as everyone rushes forward
  • March - big mostly static front from Kyiv all the way around Ukraine to Mikolaiv, big Russian losses daily, but not as quite as big as February
  • April - Northern front collapsed, daily Russian losses much lower due to having much shorter frontline

Many people predicted that with shorter frontline, Russians will throw all their units at Donbas encirclement attempt, and fighting there will be very intense, but so far that doesn't seem to be the case. Southern front in April sees about as much fighting as Southern front in March, but Northern front is all gone, so total intensity is a lot lower.

Anyway, this means that instead of doing linear fit to the whole dataset, I want to linear fit just the last N days, where that N is controlled by another slider. As early days were the most intense, this will of course delay the predicted time of Russians losing their last tank.

How far the slider can go

First, we need to modify data loading in App.svelte to set how far the data will go, currently 53 days (one less than number of days possible):

import { dataDays } from "./stores"

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})
  $dataDays = data.length - 1
  return data
}
Enter fullscreen mode Exit fullscreen mode

stores.js

We just need one more store - dataDays:

import { writable, derived } from "svelte/store"

export let lossAdjustment = writable(0)
export let projectionBasis = writable(30)
export let futureIntensity = writable(100)

export let dataDays = writable(0)

export let activeTanks = writable(3417)
export let activeArmored = writable(18543)
export let activeArt = writable(5919)

export let storageTanks = writable(10200)
export let storageArmored = writable(15500)
export let storageGood = writable(10)

export let totalTanks = derived(
  [activeTanks, storageTanks, storageGood],
  ([$activeTanks, $storageTanks, $storageGood]) => Math.round($activeTanks + $storageTanks * $storageGood / 100.0)
)
export let totalArmored = derived(
  [activeArmored, storageArmored, storageGood],
  ([$activeArmored, $storageArmored, $storageGood]) => Math.round($activeArmored + $storageArmored * $storageGood / 100.0)
)

export let totalArt = activeArt
Enter fullscreen mode Exit fullscreen mode

CommonSliders.svelte

As these form inputs repeat three times, I extracted them to a shared component:

<script>
import * as d3 from "d3"
import Slider from "./Slider.svelte"
import { lossAdjustment, projectionBasis, dataDays, futureIntensity } from "./stores"
</script>

<Slider label="Adjustment for losses data" min={-30} max={50} bind:value={$lossAdjustment} format={(v) => d3.format("+d")(v) + "%"} />
<Slider label="Projection basis in days" min={1} max={$dataDays} bind:value={$projectionBasis} format={(v) => `${v} days`} />
<Slider label="Predicted future war intensity" min={10} max={200} bind:value={$futureIntensity} format={(v) => `${v}%`} />
Enter fullscreen mode Exit fullscreen mode

LossesGraph.svelte

This file needed new calculations for currentDestroyRate. I also defined local at function as Safari still doesn't have Array.prototype.at, and adding polyfills is a bit of a hassle.

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

export let lossData, total, label

let adjust = (data, adjustmentLoss) => data.map(({date, unit}) => ({date, unit: Math.round(unit * (1 + adjustmentLoss/100))}))
let at = (array, idx) => ((idx < 0) ? array[array.length + idx] : array[idx])

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

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

$: timeInProjection = at(adjustedData, -$projectionBasis-1).date - at(adjustedData, -1).date
$: destroyedInProjection = at(adjustedData, -$projectionBasis-1).unit - at(adjustedData, -1).unit
$: currentDestroyRate = destroyedInProjection / timeInProjection

$: 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], at(adjustedData, -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"))

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

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

$: yAxisR =  d3
  .axisRight()
  .scale(yScaleR)
  .tickFormat(n => `${n}%`)
</script>

<Graph {pathData} {trendPathData} {totalPathData} {xAxis} {yAxisL} {yAxisR} />
<div>Russia will lose its last {label} on {d3.timeFormat("%e %b %Y")(lastDestroyedDate)}</div>
Enter fullscreen mode Exit fullscreen mode

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 add some projections for personel losses.

Top comments (0)