DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

Open Source Adventures: Episode 39: Loss Percentage Axis for Russian Losses App

There's a few features I want to add to the Russian Losses App, and the first is % losses axis.

This is a visual design pattern that's used sometimes - there are two Y axes. Y axis on the left is absolute numbers losses, Y axis on the right is percentage losses.

This requires some care. For example if total number of armored vehicles is 20093, we want to round this to the next round number (22000), so axis on the left goes from 0 to 20093. But axis on the right needs to go from 0% to 100%, where 100% lines up with 20093, not with 22000!

Here's what we want:



Graph component just displays what we pass. We pass yAxisL and yAxisR and the component just displays them in proper places without thinking much about it.

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

<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-left"><Axis axis={yAxisL}/></g>
  <g class="y-axis-right"><Axis axis={yAxisR}/></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-left {
  transform: translate(50px, 20px);
.y-axis-right {
  transform: translate(750px, 20px);
Enter fullscreen mode Exit fullscreen mode


All the logic is in LossesGraph component.

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"))

$: yAxisL = d3

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

$: yAxisR =  d3
  .tickFormat(n => `${n}%`)

<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

The most important part is yScaleR definition, and in particular its range, which is defined in terms of pre-nice-ified yScale. So 0% needs to line up with yScale(0), but 100% with yScale(unitsMax) (that is yScale(20093) in our example) not with yScale(22000).

yAxisR is then just a normal percentage scale, other than appending % to every tick it doesn't do anything special.

Story so far

All the code is on GitHub.

I deployed this on GitHub Pages, you can see it here.

Coming next

For the next few episodes, I have a few more features I want to add to the Russian Losses App.

Top comments (0)