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
}
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
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}%`} />
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>
Story so far
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)