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.
<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.
<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.
<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.
<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:
<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:
<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.
<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
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)