DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 40: Svelte Stores for Russian Losses App

State should generally live inside components, but it's not always possible without excessive callbacks. For such situations, Svelte has stores.

Right now the app has 3 components (tank losses, armored vehicle losses, and artillery losses), and some of the sliders are shared and really should move elsewhere. And if we're moving them, we might just as well move all the sliders into a single store.

stores.js

Unfortunately or derived stores we need to list dependencies explicitly, as this is .js file not .svelte file, so we get none of the Svelte automatic reactivity.

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

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

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

TankForm.svelte

The other two are analogous.

<script>
import * as d3 from "d3"
import Slider from "./Slider.svelte"
import { lossAdjustment, futureIntensity, activeTanks, storageTanks, storageGood, totalTanks } from "./stores"
</script>

<form>
  <Slider label="Adjustment for losses data" min={-30} max={50} bind:value={$lossAdjustment} format={(v) => d3.format("+d")(v) + "%"} />
  <Slider label="Predicted future war intensity" min={10} max={200} bind:value={$futureIntensity} format={(v) => `${v}%`} />
  <Slider label="Russian tanks at start of war" min={2500} max={3500} bind:value={$activeTanks} format={(v) => v} />
  <Slider label="Russian tanks in storage" min={8000} max={12000} bind:value={$storageTanks} 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>{$totalTanks}</span>
  </div>
</form>

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

Instead of export leting various variables, we import them all from stores, and refer to them with a $. Svelte handles all the callbacks automatically. The other two are analogous.

TankLosses.svelte

<script>
import TankForm from "./TankForm.svelte"
import LossesGraph from "./LossesGraph.svelte"
import { totalTanks } from "./stores"

export let data

let lossData = data.map(({date, tank}) => ({date, unit: tank}))
</script>

<h1>Russian Tank Losses</h1>
<LossesGraph {lossData} total={$totalTanks} label="tank" />
<TankForm />
Enter fullscreen mode Exit fullscreen mode

The component is a lot simpler now, the only thing we need to do is specify which total to use (tank, armored, or artillery).

LossesGraph.svelte

<script>
import * as d3 from "d3"
import Graph from "./Graph.svelte"
import { lossAdjustment, 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 [minDate, maxDate] = d3.extent(lossData, d => d.date)

$: adjustedData = adjust(lossData, $lossAdjustment)
$: 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"))

$: 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

Arguably some of the code could be migrated to the stores, but it won't hurt to keep it here.

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 add some more sophisticated way of predicting future changes.

Oldest comments (0)