DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 36: Using D3 to figure out when Russia will lose its last armored vehicle

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.


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

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(,
  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()

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

:global(body) {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
Enter fullscreen mode Exit fullscreen mode


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

import TankForm from "./TankForm.svelte"
import LossesGraph from "./LossesGraph.svelte"

export let data

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

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

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


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

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)


  <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}%`} />

    <span>Total usable tanks</span>

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


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

export let label, min, max, value, format
let id = Math.random().toString(36).slice(2)

<label for={id}>{label}:</label>
<input type="range" {min} {max} bind:value id={id} />
Enter fullscreen mode Exit fullscreen mode


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

import * as d3 from "d3"
import Graph from "./Graph.svelte"

export let lossData, total, adjustmentLoss, futureIntensity, label

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

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

$: 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])
  .range([500, 0])

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

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

$: totalPathData = d3.line()
  ([minDate, lastDestroyedDate])

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

$: yAxis = d3

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


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

import Axis from "./Axis.svelte"
export let pathData, trendPathData, totalPathData, xAxis, yAxis

<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 class="x-axis"><Axis axis={xAxis}/></g>
  <g class="y-axis"><Axis axis={yAxis}/></g>

svg {
  width: 800px;
  max-width: 100vw;
  display: block;
.graph {
  transform: translate(50px, 20px);
path {
  fill: none;
} {
  stroke: red;
  stroke-width: 1.5;
path.trendline {
  stroke: red;
  stroke-width: 1.5;
  stroke-dasharray: 3px;
} {
  stroke: blue;
  stroke-width: 1.5;
.x-axis {
  transform: translate(50px, 520px);
.y-axis {
  transform: translate(50px, 20px);
Enter fullscreen mode Exit fullscreen mode


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

import * as d3 from "d3"
export let axis

let axisNode

$: {"*").remove()

<g bind:this={axisNode}></g>
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

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

Top comments (0)