I needed pleasant range values for the charting API in Fuse. These are the values written by the ticks on the plot, typically on the Y-axis. In the interest of simplicity it should just work by default; provide the plot with raw data and let it work out good values. It’s a small but important detail in producing an aesthetically appealing chart. The algorithm I came up with is rather simple, but seems to work well.
Range and steps
Let’s look at what we’re contending with first. The plot will be given a set of data, from which we calculate a maximum and minimum value. We need to divide this into a number of ticks. If we simply divided the range by a step count with no further processing we end up with ugly numbers like this:
A quick test to root out robotic colleagues is ask them whether they find these range values pleasant.
It doesn’t look as nice as the earlier chart. It’s missing a zero-line, and it requires more than a glance to understand the tick ranges.
The algorithm needs to adjust those values, the maximum, minimum, and step count, to produce something a human might choose.
The algorithm
I created a GetStepping
function that modifies the values. For the example chart the input steps
is always 8, and the min
and max
are based on random data in the range of roughly -150...150
. In my test app I use a lot of constrained random ranges to help identify corner cases that may come up with actual data.
1 |
static public void GetStepping( ref int steps, ref float min, ref float max ) |
Hard-coding a step value, like
8
, is not very practical for responsive displays. The API also provides for providing a size value instead, in which case the step count will be calculate based on the available space.
The full code is at the bottom of the article. I’ll go over bits of it here, adjusting slightly for clarity.
Powers of 10
We like nice clean numbers, such as multiples of 10. The first part of the code quantizes the range (max - min
).
1 2 3 |
var step = range / steps; var mag10 = Math.Ceil( Math.Log(step) / Math.Log(10) ); var stepSize = Math.Pow( 10, mag10 ); |
The stepSize
gives us a decent step range for the ticks. We can adjust the input values with this and see what the chart might look like.
1 2 3 4 |
//oMin/oMax are copies of the input min and max values, so we always have the original min = Math.Floor( oMin / stepSize ) * stepSize; max = Math.Ceil( oMax / stepSize ) * stepSize; steps = (int)Math.Round( (max-min) / stepSize ); |
This converts the min/max into multiples of the stepSize
and calculates the required number of steps based on these new values. The Round
is required to deal with floating point imprecision, otherwise the integer conversion would truncate a value like 3.99976
to 3
, not the desired 4
.
The numbers are clear at a quick glance, but there aren’t very many of them; the stepSize
is quite large. There’s also a bit too much empty space at the top/bottom.
Other multiples
What other values do we find appealing in a chart? I used my powers of intuition, otherwise known as searching the internet, and found a pattern of pleasing values. The values are 2
, 2.5
, and 5
multiplied by various powers of ten, like 20
, 250
, and 0.5
.
We seem to have a strong cultural liking for these values. We’ve even hard-coded this liking into our currency. The common denominations, both coins and bills, fit the pattern: 1¢, 2¢, 5¢, 10¢, 20¢, 25¢, 50¢, 5€, 10€, 20€, 50€, 100€.
I just needed to find a way to use these various values. Look back to the chart with the big 100 step increments, notice there aren’t many steps, far less than the desired number provided on input. If we reduce the step size we’ll get more steps.
Reducing to desired multiples of 2
, 2.5
and 5
is done by dividing our first step size by 5
, 4
and 2
. The algorithm does this in a loop:
1 2 3 4 |
var trySteps = new[]{ 5, 4, 2, 1 }; for (int i=0; i < trySteps.Length; ++i ) { var stepSize = baseStepSize / trySteps[i]; |
1
is included to consider the original multiple of 10. For each trySteps
we calculate a min
, max
and steps
. Once steps
is below the desired number of steps we stop.
1 2 |
if (steps <= desiredSteps) break; |
Source Code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
static public void GetStepping( ref int steps, ref float min, ref float max ) { var oMin = min; var oMax = max; var desiredSteps = steps; var range = max - min; //find magnitude and steps in powers of 10 var step = range / steps; var mag10 = Math.Ceil( Math.Log(step) / Math.Log(10) ); var baseStepSize = Math.Pow( 10, mag10 ); //find common divisions to get closer to desiredSteps var trySteps = new[]{ 5, 4, 2, 1 }; for (int i=0; i < trySteps.Length; ++i ) { var stepSize = baseStepSize / trySteps[i]; var ns = Math.Round( range / stepSize ); //bail if anything didn't work, We can't check float.ZeroTolernace anywhere since we should //work on arbitrary range values if (Float.IsNaN(baseStepSize) || Float.IsNaN(ns) || (ns < 1)) return; min = Math.Floor( oMin / stepSize ) * stepSize; max = Math.Ceil( oMax / stepSize ) * stepSize; steps = (int)Math.Round( (max-min) / stepSize ); if (steps <= desiredSteps) break; } } |