Algorithm for "nice" grid line intervals on a graph

Viewed 42624

I need a reasonably smart algorithm to come up with "nice" grid lines for a graph (chart).

For example, assume a bar chart with values of 10, 30, 72 and 60. You know:

Min value: 10 Max value: 72 Range: 62

The first question is: what do you start from? In this case, 0 would be the intuitive value but this won't hold up on other data sets so I'm guessing:

Grid min value should be either 0 or a "nice" value lower than the min value of the data in range. Alternatively, it can be specified.

Grid max value should be a "nice" value above the max value in the range. Alternatively, it can be specified (eg you might want 0 to 100 if you're showing percentages, irrespective of the actual values).

The number of grid lines (ticks) in the range should be either specified or a number within a given range (eg 3-8) such that the values are "nice" (ie round numbers) and you maximise use of the chart area. In our example, 80 would be a sensible max as that would use 90% of the chart height (72/80) whereas 100 would create more wasted space.

Anyone know of a good algorithm for this? Language is irrelevant as I'll implement it in what I need to.

16 Answers

I've done this with kind of a brute force method. First, figure out the maximum number of tick marks you can fit into the space. Divide the total range of values by the number of ticks; this is the minimum spacing of the tick. Now calculate the floor of the logarithm base 10 to get the magnitude of the tick, and divide by this value. You should end up with something in the range of 1 to 10. Simply choose the round number greater than or equal to the value and multiply it by the logarithm calculated earlier. This is your final tick spacing.

Example in Python:

import math

def BestTick(largest, mostticks):
    minimum = largest / mostticks
    magnitude = 10 ** math.floor(math.log(minimum, 10))
    residual = minimum / magnitude
    if residual > 5:
        tick = 10 * magnitude
    elif residual > 2:
        tick = 5 * magnitude
    elif residual > 1:
        tick = 2 * magnitude
    else:
        tick = magnitude
    return tick

Edit: you are free to alter the selection of "nice" intervals. One commenter appears to be dissatisfied with the selections provided, because the actual number of ticks can be up to 2.5 times less than the maximum. Here's a slight modification that defines a table for the nice intervals. In the example, I've expanded the selections so that the number of ticks won't be less than 3/5 of the maximum.

import bisect

def BestTick2(largest, mostticks):
    minimum = largest / mostticks
    magnitude = 10 ** math.floor(math.log(minimum, 10))
    residual = minimum / magnitude
    # this table must begin with 1 and end with 10
    table = [1, 1.5, 2, 3, 5, 7, 10]
    tick = table[bisect.bisect_right(table, residual)] if residual < 10 else 10
    return tick * magnitude

There are 2 pieces to the problem:

  1. Determine the order of magnitude involved, and
  2. Round to something convenient.

You can handle the first part by using logarithms:

range = max - min;  
exponent = int(log(range));       // See comment below.
magnitude = pow(10, exponent);

So, for example, if your range is from 50 - 1200, the exponent is 3 and the magnitude is 1000.

Then deal with the second part by deciding how many subdivisions you want in your grid:

value_per_division = magnitude / subdivisions;

This is a rough calculation because the exponent has been truncated to an integer. You may want to tweak the exponent calculation to handle boundary conditions better, e.g. by rounding instead of taking the int() if you end up with too many subdivisions.

I use the following algorithm. It's similar to others posted here but it's the first example in C#.

public static class AxisUtil
{
    public static float CalcStepSize(float range, float targetSteps)
    {
        // calculate an initial guess at step size
        var tempStep = range/targetSteps;

        // get the magnitude of the step size
        var mag = (float)Math.Floor(Math.Log10(tempStep));
        var magPow = (float)Math.Pow(10, mag);

        // calculate most significant digit of the new step size
        var magMsd = (int)(tempStep/magPow + 0.5);

        // promote the MSD to either 1, 2, or 5
        if (magMsd > 5)
            magMsd = 10;
        else if (magMsd > 2)
            magMsd = 5;
        else if (magMsd > 1)
            magMsd = 2;

        return magMsd*magPow;
    }
}

CPAN provides an implementation here (see source link)

See also Tickmark algorithm for a graph axis

FYI, with your sample data:

  • Maple: Min=8, Max=74, Labels=10,20,..,60,70, Ticks=10,12,14,..70,72
  • MATLAB: Min=10, Max=80, Labels=10,20,,..,60,80

Another idea is to have the range of the axis be the range of the values, but put the tick marks at the appropriate position.. i.e. for 7 to 22 do:

[- - - | - - - - | - - - - | - - ]
       10        15        20

As for selecting the tick spacing, I would suggest any number of the form 10^x * i / n, where i < n, and 0 < n < 10. Generate this list, and sort them, and you can find the largest number smaller than value_per_division (as in adam_liss) using a binary search.

Answer that can dynamically always plot 0, handle positive and negatives, and small and large numbers, gives the tick interval size and how many to plot; written in Go

forcePlotZero changes how the max values are rounded so it'll always make a nice multiple to then get back to zero. Example:

if forcePlotZero == false then 237 --> 240

if forcePlotZero == true then 237 --> 300

Intervals are calculated by getting the multiple of 10/100/1000 etc for max and then subtracting till the cumulative total of these subtractions is < min

Here's the output from the function, along with showing forcePlotZero

Force to plot zero max and min inputs rounded max and min intervals
forcePlotZero=false min: -104 max: 240 minned: -160 maxed: 240 intervalCount: 5 intervalSize: 100
forcePlotZero=true min: -104 max: 240 minned: -200 maxed: 300 intervalCount: 6 intervalSize: 100
forcePlotZero=false min: 40 max: 1240 minned: 0 maxed: 1300 intervalCount: 14 intervalSize: 100
forcePlotZero=false min: 200 max: 240 minned: 190 maxed: 240 intervalCount: 6 intervalSize: 10
forcePlotZero=false min: 0.7 max: 1.12 minned: 0.6 maxed: 1.2 intervalCount: 7 intervalSize: 0.1
forcePlotZero=false min: -70.5 max: -12.5 minned: -80 maxed: -10 intervalCount: 8 intervalSize: 10

Here's the playground link https://play.golang.org/p/1IhiX_hRQvo

func getMaxMinIntervals(max float64, min float64, forcePlotZero bool) (maxRounded float64, minRounded float64, intervalCount float64, intervalSize float64) {

//STEP 1: start off determining the maxRounded value for the axis
precision := 0.0
precisionDampener := 0.0 //adjusts to prevent 235 going to 300, instead dampens the scaling to get 240
epsilon := 0.0000001
if math.Abs(max) >= 0 && math.Abs(max) < 2 {
    precision = math.Floor(-math.Log10(epsilon + math.Abs(max) - math.Floor(math.Abs(max)))) //counting number of zeros between decimal point and rightward digits
    precisionDampener = 1
    precision = precision + precisionDampener
} else if math.Abs(max) >= 2 && math.Abs(max) < 100 {
    precision = math.Ceil(math.Log10(math.Abs(max)+1)) * -1 //else count number of digits before decimal point
    precisionDampener = 1
    precision = precision + precisionDampener
} else {
    precision = math.Ceil(math.Log10(math.Abs(max)+1)) * -1 //else count number of digits before decimal point
    precisionDampener = 2
    if forcePlotZero == true {
        precisionDampener = 1
    }
    precision = precision + precisionDampener
}

useThisFactorForIntervalCalculation := 0.0 // this is needed because intervals are calculated from the max value with a zero origin, this uses range for min - max
if max < 0 {
    maxRounded = (math.Floor(math.Abs(max)*(math.Pow10(int(precision)))) / math.Pow10(int(precision)) * -1)
    useThisFactorForIntervalCalculation = (math.Floor(math.Abs(max)*(math.Pow10(int(precision)))) / math.Pow10(int(precision))) + ((math.Ceil(math.Abs(min)*(math.Pow10(int(precision)))) / math.Pow10(int(precision))) * -1)
} else {
    maxRounded = math.Ceil(max*(math.Pow10(int(precision)))) / math.Pow10(int(precision))
    useThisFactorForIntervalCalculation = maxRounded
}

minNumberOfIntervals := 2.0
maxNumberOfIntervals := 19.0
intervalSize = 0.001
intervalCount = minNumberOfIntervals

//STEP 2: get interval size (the step size on the axis)
for {
    if math.Abs(useThisFactorForIntervalCalculation)/intervalSize < minNumberOfIntervals || math.Abs(useThisFactorForIntervalCalculation)/intervalSize > maxNumberOfIntervals {
        intervalSize = intervalSize * 10
    } else {
        break
    }
}

//STEP 3: check that intervals are not too large, safety for max and min values that are close together (240, 220 etc)
for {
    if max-min < intervalSize {
        intervalSize = intervalSize / 10
    } else {
        break
    }
}

//STEP 4: now we can get minRounded by adding the interval size to 0 till we get to the point where another increment would make cumulative increments > min, opposite for negative in
minRounded = 0.0

if min >= 0 {
    for {
        if minRounded < min {
            minRounded = minRounded + intervalSize
        } else {
            minRounded = minRounded - intervalSize
            break
        }
    }
} else {
    minRounded = maxRounded //keep going down, decreasing by the interval size till minRounded < min
    for {
        if minRounded > min {
            minRounded = minRounded - intervalSize

        } else {
            break
        }
    }
}

//STEP 5: get number of intervals to draw
intervalCount = (maxRounded - minRounded) / intervalSize
intervalCount = math.Ceil(intervalCount) + 1 // include the origin as an interval

//STEP 6: Check that the intervalCount isn't too high
if intervalCount-1 >= (intervalSize * 2) && intervalCount > maxNumberOfIntervals {
    intervalCount = math.Ceil(intervalCount / 2)
    intervalSize *= 2
}

return}

This is in python and for base 10. Doesn't cover all your questions but I think you can build on it

import numpy as np

def create_ticks(lo,hi):
    s = 10**(np.floor(np.log10(hi - lo)))
    start = s * np.floor(lo / s)
    end = s * np.ceil(hi / s)
    ticks = [start]
    t = start
    while (t <  end):
        ticks += [t]
        t = t + s
        
    return ticks
Related