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.

Image of Timescale

Timescale – the developer's data platform for modern apps, built on PostgreSQL

Timescale Cloud is PostgreSQL optimized for speed, scale, and performance. Over 3 million IoT, AI, crypto, and dev tool apps are powered by Timescale. Try it free today! No credit card required.

Try free

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Explore a sea of insights with this enlightening post, highly esteemed within the nurturing DEV Community. Coders of all stripes are invited to participate and contribute to our shared knowledge.

Expressing gratitude with a simple "thank you" can make a big impact. Leave your thanks in the comments!

On DEV, exchanging ideas smooths our way and strengthens our community bonds. Found this useful? A quick note of thanks to the author can mean a lot.

Okay