DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

2 2

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:

[IMAGE]

Graph.svelte

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.

<script>
import Axis from "./Axis.svelte"
export let pathData, trendPathData, totalPathData, xAxis, yAxisL, yAxisR
</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-left"><Axis axis={yAxisL}/></g>
  <g class="y-axis-right"><Axis axis={yAxisR}/></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-left {
  transform: translate(50px, 20px);
}
.y-axis-right {
  transform: translate(750px, 20px);
}
</style>
Enter fullscreen mode Exit fullscreen mode

LossesGraph.svelte

All the logic is in LossesGraph component.

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

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

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.

Sentry blog image

How to reduce TTFB

In the past few years in the web dev world, we’ve seen a significant push towards rendering our websites on the server. Doing so is better for SEO and performs better on low-powered devices, but one thing we had to sacrifice is TTFB.

In this article, we’ll see how we can identify what makes our TTFB high so we can fix it.

Read more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs