Open Source Adventures: Episode 34: Making Last Russian Tank Predictor mobile friendly

There are two big issues with the app we have so far:

  • it just plain doesn't work in Safari, due to its lack of Array.prototype.at
  • on small mobile screens the graph overflows the screen slightly

The most common way to develop web apps is to make them work in Desktop Chrome first (Desktop Firefox would work too), as you're coding on a computer already, not a phone, and Chrome has the most complete set of functionality, and best developer tooling.

Chrome and Chrome-like browsers are so dominant these days, and Firefox is largely compatible with them, that this works quite well, and most web sites will just work everywhere.

And the last thing is splitting current Graph component into TankLosses parent which does the calculations, and Graph which only does display.

Safari

Safari is always a few years behind times. There are complex polyfill solutions, but in this case it's just a single line of code we need to fix. Instead of this:

adjustedData.at(-1)

We can do this:

adjustedData[adjustedData.length - 1]

Definitely uglier, but it's just one tiny thing, not worth setting up whole polyfill system over.

Small screen support

It's a lot easier to do graphs if you know size of the target, than support any size. That works on desktop, but not so well on mobile.

Fortunately SVG has a nice trick, and it supports having separate external size (which will be device dependent) and internal size (which is constant and we'll use to put everything in the right places).

We can declare internal size with <svg viewBox="0 0 800 600">, then external size with:

svg {
  width: 800px;
  max-width: 100vw;
  display: block;
}

src/TankLosses.svelte

Here's the parent TankLosses component, responsible for the whole app except for async loading of data, as that's usually best kept outside:

<script>
import * as d3 from "d3"
import Form from "./Form.svelte"
import Graph from "./Graph.svelte"

export let data

let adjust = (data, adjustmentLoss) => data.map(({date, tank}) => ({date, tank: Math.round(tank * (1 + adjustmentLoss/100))}))

// put some dummy data to avoid issues with initialization order
let adjustmentLoss = 0, futureIntensity = 100, totalTanks = 0

let [minDate, maxDate] = d3.extent(data, d => d.date)

$: adjustedData = adjust(data, adjustmentLoss)
$: alreadyDestroyedTanks = d3.max(adjustedData, d => d.tank)
$: tanksMax = Math.max(alreadyDestroyedTanks, totalTanks)

$: currentTankRate = alreadyDestroyedTanks / (maxDate - minDate)
$: futureTankRate = (currentTankRate * futureIntensity / 100.0)
$: tanksTodo = totalTanks - alreadyDestroyedTanks
$: lastTankDate = new Date(+maxDate + (tanksTodo / futureTankRate))

$: xScale = d3.scaleTime()
  .domain([minDate, lastTankDate])
  .range([0, 700])

$: yScale = d3.scaleLinear()
  .domain([0, tanksMax])
  .nice()
  .range([500, 0])

$: pathData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.tank))
  (adjustedData)

$: trendPathData = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.tank))
  ([adjustedData[0], adjustedData[adjustedData.length - 1], {tank: totalTanks, date: lastTankDate}])

$: tankTotalPathData = d3.line()
  .x(xScale)
  .y(yScale(tanksMax))
  ([minDate, lastTankDate])

$: xAxis = d3.axisBottom()
  .scale(xScale)
  .tickFormat(d3.timeFormat("%e %b %Y"))

$: yAxis = d3
  .axisLeft()
  .scale(yScale)
</script>

<h1>Russian Tank Losses</h1>
<Graph {pathData} {trendPathData} {tankTotalPathData} {xAxis} {yAxis}/>
<Form bind:adjustmentLoss bind:futureIntensity bind:totalTanks />
<div>Russia will lose its last tank on {d3.timeFormat("%e %b %Y")(lastTankDate)}</div>

src/Graph.svelte

And here's the Graph component, it's only responsible for displaying data that's already calculated:

<script>
import Axis from "./Axis.svelte"
export let pathData, trendPathData, tankTotalPathData, xAxis, yAxis
</script>

<svg viewBox="0 0 800 600">
  <g class="graph">
    <path class="data" d={pathData}/>
    <path class="trendline" d={trendPathData}/>
    <path class="tanktotal" d={tankTotalPathData}/>
  </g>
  <g class="x-axis"><Axis axis={xAxis}/></g>
  <g class="y-axis"><Axis axis={yAxis}/></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.tanktotal {
  stroke: blue;
  stroke-width: 1.5;
}
.x-axis {
  transform: translate(50px, 520px);
}
.y-axis {
  transform: translate(50px, 20px);
}
</style>

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 try to do a few more things with the app.