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