Nice numbers part 2: scales

In part 1 I talked about a problem when developing scales built algorithmically. For example choosing numbers on an axis:

If you want five ticks on the side of bar chart, and the range is 0 – 8, you could do 0, 2, 4, 6, 8. But what about 0 – 5? That would be 0, 1.25, 2.5, 3.75, 5. How about if the chart is displayed on a small screen and you can only fit in 4 ticks, then 0 – 8 is 0, 2.666, 5.333, 8. Those are not nice numbers.

So, an axis (say on a column chart) for those values would look like this:

A poorly formed scale

 

In that post we developed an algorithm for rounding numbers to an aesthetically pleasing value. However, the value returned from that function was a string – a human readable representation of the number, such as “1.2 trillion”. In order to tackle the problem described above we will need to keep the number as a Number.


function getNiceNumber(uglyNumber, precision) {
    if (! precision) precision == 2;

    return parseFloat((uglyNumber).toPrecision(2));
}

function getDisplayNumber(niceNumber) {
    var order = Math.floor(Math.log10(niceNumber));

    var suffix = '';
    if (order >= 12) {
        niceNumber = niceNumber / Math.pow(10, 12);
        suffix = ' trillion';
    } else if (order >= 9) {
        niceNumber = niceNumber / Math.pow(10, 9);
        suffix = 'bn';
    } else if (order >= 6) {
        niceNumber = niceNumber / Math.pow(10, 6);
        suffix = 'm';
    } else if (order >= 3) {
        niceNumber = niceNumber / Math.pow(10, 3);
        suffix = 'k';
    } else if (order <= -3) {
        niceNumber = niceNumber / Math.pow(10, order);
        suffix = ' × 10' + order + '';       
    }
  
    return niceNumber + suffix;
}

var uglyNumber = 0.000326343;
var niceNumber = getNiceNumber(uglyNumber);
console.log(getDisplayNumber(niceNumber));

As before, that logs:

3.3 × 10-4

Choosing an increment

Now, to return to the problem at hand. In our example, generating 4 tick marks on a scale from 0 – 8 doesn’t give good results. We can see that it won’t. 4 tick marks equates to 3 regions: look at the axis illustration, there are 3 regions between the 4 ticks; in general, for n ticks there are always n – 1 regions. Therefore the (bad) increment chosen in that example is 8 (the range) divided by 3, which is 2.66 reoccurring.

So, what would make a good increment? Well, a nice number would be a good start, probably. If we  decrease the precision to 1, the increment becomes 3:


function getNiceIncrement(start, end, numberOfTicks) {
    var range = end - start;
    var numberOfRegions = numberOfTicks - 1;

    return getNiceNumber(range / numberOfRegions);
}

var start = 0;
var end = 8;

var increment = getNiceIncrement(start, end, 4);

console.log(increment);

Which will log out “3”.

Now, for the example case we could say we are done. If we generate the tick marks by incrementing from the start we get:

0,3,6,9

Which quite nicely covers the area of interest and only uses nice numbers. As ever, it’s not that simple though. How about 0 to 4, with 4 ticks? Our nice increment is then 1 (4/3 rounded down); the scale would be:

0,1,2,3

It doesn’t cover the range, so is completely invalid. The obvious solution here (assuming you are not willing to use 5 ticks) is to always round numbers up. However that is not quite as trivial as it may appear: 1203.5 should round up to 2000, not 1204. As with the getDisplayNumber function, the solution lies in logarithms.


function getNiceIncrement(start, end, numberOfTicks) {
    var range = end - start;
    var numberOfRegions = numberOfTicks - 1;

    var uglyIncrement = range / numberOfRegions;

    var order = Math.floor(Math.log10(uglyIncrement));
    var divisor = Math.pow(10, order);

    return = Math.ceil(uglyIncrement / divisor) * divisor;
}

var start = 0;
var end = 4;

var increment = getNiceIncrement(start, end, 4);

console.log(increment);

This time, the generated increment is 2. That is better, we get 0,2,4,6.

By way of a quick explanation into the algorithm, rounding down the log-base-ten of the ugly increment gives us the number of digits before the decimal place minus one (i.e. the integer you would raise 10 to to get the largest possible number which is still less than the ugly increment), I will call this the order. Dividing the ugly increment by 10 raised to the power of the order always gives a number between 1 and 10; which we can round up, safely, then multiple by the order to return it to the correct size. By example:

  • start = 120010, end = 863209, numberOfTicks = 4
  • range = 743199
  • numberOfRegions = 3
  • uglyIncrement = 247733
  • order = 5 (as there are 6 digits or 105 = 100000 < 247733
  • divisor = 100000
  • uglyIncrement / divisor = 2.47733
  • Math.ceil(uglyIncrement / divisor) = 3
  • Math.ceil(uglyIncrement / divisor) * divisor = 300000

So, to recap, we now have a function that will generate nice increments and another that will generate display numbers (and getNiceNumber, although that is no longer used).

Centring the range

Consider the case where the scale runs from 11 to 15, with 4 ticks. The range is still 4, so the nice increment will be calculated as 2 and the scale will run:

11,13,15,17

That is okay, but the 17 seems superfluous as our original scale ended at the penultimate tick mark. We can account for this by moving the start tick backwards to centre the range. We should be a little cautious, though, in the case where the start of the range is zero. In the case of 0 – 4 our scale was 0,2,4,6 – would -1,1,3,5 have been better? Probably not: the negative number are an unnecessary complication. So, we might use:


function getNiceTickMarks(start, end, numberOfTicks) {
    var range = end - start;
    var numberOfRegions = numberOfTicks - 1;
    var niceIncrement = getNiceIncrement(range, numberOfRegions);

    var rangeOfTicks = niceIncrement * numberOfRegions;

    if (rangeOfTicks > range) {
        if (start >= 0 && start < niceIncrement) {
            start = 0;
        } else {
            start -= (rangeOfTicks - range) / 2;
        }
    }

    var tickMarks = [];

    var counter = start;
    while (tickMarks.length < numberOfTicks) {
        tickMarks.push(counter);
        counter += niceIncrement;
    }

    return tickMarks;
}

function getNiceIncrement(range, numberOfRegions) {
    var uglyIncrement = range / numberOfRegions;

    var order = Math.floor(Math.log10(uglyIncrement));
    var divisor = Math.pow(10, order);

    return Math.ceil(uglyIncrement / divisor) * divisor;
}

var start = 11;
var end = 15;

var tickMarks = getNiceTickMarks(start, end, 4);

console.log(tickMarks);

That will return:

10,12,14,16

Perfect. And if we had 0 – 4 (or 0.5 – 4.5, etc) we would get:

0,2,4,6

That, for now at least, is all I have to say on nice numbers.

One Response to “Nice numbers part 2: scales”

  1. […] that out in our visualizations and I’ll write about choosing sensible numbers in another post […]

Leave a Reply

Your email address will not be published. Required fields are marked *