Choosing an attractive linear scale for a graph's Y Axis

后端 未结 13 1427
予麋鹿
予麋鹿 2020-12-07 08:42

I\'m writing a bit of code to display a bar (or line) graph in our software. Everything\'s going fine. The thing that\'s got me stumped is labeling the Y axis.

The

相关标签:
13条回答
  • 2020-12-07 08:56

    The above algorithms do not take into consideration the case when the range between min and max value is too small. And what if these values are a lot higher than zero? Then, we have the possibility to start the y-axis with a value higher than zero. Also, in order to avoid our line to be entirely on the upper or the down side of the graph, we have to give it some "air to breathe".

    To cover those cases I wrote (on PHP) the above code:

    function calculateStartingPoint($min, $ticks, $times, $scale) {
    
        $starting_point = $min - floor((($ticks - $times) * $scale)/2);
    
        if ($starting_point < 0) {
            $starting_point = 0;
        } else {
            $starting_point = floor($starting_point / $scale) * $scale;
            $starting_point = ceil($starting_point / $scale) * $scale;
            $starting_point = round($starting_point / $scale) * $scale;
        }
        return $starting_point;
    }
    
    function calculateYaxis($min, $max, $ticks = 7)
    {
        print "Min = " . $min . "\n";
        print "Max = " . $max . "\n";
    
        $range = $max - $min;
        $step = floor($range/$ticks);
        print "First step is " . $step . "\n";
        $available_steps = array(5, 10, 20, 25, 30, 40, 50, 100, 150, 200, 300, 400, 500);
        $distance = 1000;
        $scale = 0;
    
        foreach ($available_steps as $i) {
            if (($i - $step < $distance) && ($i - $step > 0)) {
                $distance = $i - $step;
                $scale = $i;
            }
        }
    
        print "Final scale step is " . $scale . "\n";
    
        $times = floor($range/$scale);
        print "range/scale = " . $times . "\n";
    
        print "floor(times/2) = " . floor($times/2) . "\n";
    
        $starting_point = calculateStartingPoint($min, $ticks, $times, $scale);
    
        if ($starting_point + ($ticks * $scale) < $max) {
            $ticks += 1;
        }
    
        print "starting_point = " . $starting_point . "\n";
    
        // result calculation
        $result = [];
        for ($x = 0; $x <= $ticks; $x++) {
            $result[] = $starting_point + ($x * $scale);
        }
        return $result;
    }
    
    0 讨论(0)
  • 2020-12-07 09:02

    This solution is based on a Java example I found.

    const niceScale = ( minPoint, maxPoint, maxTicks) => {
        const niceNum = ( localRange,  round) => {
            var exponent,fraction,niceFraction;
            exponent = Math.floor(Math.log10(localRange));
            fraction = localRange / Math.pow(10, exponent);
            if (round) {
                if (fraction < 1.5) niceFraction = 1;
                else if (fraction < 3) niceFraction = 2;
                else if (fraction < 7) niceFraction = 5;
                else niceFraction = 10;
            } else {
                if (fraction <= 1) niceFraction = 1;
                else if (fraction <= 2) niceFraction = 2;
                else if (fraction <= 5) niceFraction = 5;
                else niceFraction = 10;
            }
            return niceFraction * Math.pow(10, exponent);
        }
        const result = [];
        const range = niceNum(maxPoint - minPoint, false);
        const stepSize = niceNum(range / (maxTicks - 1), true);
        const lBound = Math.floor(minPoint / stepSize) * stepSize;
        const uBound = Math.ceil(maxPoint / stepSize) * stepSize;
        for(let i=lBound;i<=uBound;i+=stepSize) result.push(i);
        return result;
    };
    console.log(niceScale(15,234,6));
    // > [0, 100, 200, 300]

    0 讨论(0)
  • 2020-12-07 09:06

    For anyone who need this in ES5 Javascript, been wrestling a bit, but here it is:

    var min=52;
    var max=173;
    var actualHeight=500; // 500 pixels high graph
    
    var tickCount =Math.round(actualHeight/100); 
    // we want lines about every 100 pixels.
    
    if(tickCount <3) tickCount =3; 
    var range=Math.abs(max-min);
    var unroundedTickSize = range/(tickCount-1);
    var x = Math.ceil(Math.log10(unroundedTickSize)-1);
    var pow10x = Math.pow(10, x);
    var roundedTickRange = Math.ceil(unroundedTickSize / pow10x) * pow10x;
    var min_rounded=roundedTickRange * Math.floor(min/roundedTickRange);
    var max_rounded= roundedTickRange * Math.ceil(max/roundedTickRange);
    var nr=tickCount;
    var str="";
    for(var x=min_rounded;x<=max_rounded;x+=roundedTickRange)
    {
        str+=x+", ";
    }
    console.log("nice Y axis "+str);    
    

    Based on the excellent answer by Toon Krijtje.

    0 讨论(0)
  • 2020-12-07 09:07

    The answer by Toon Krijthe does work most of the time. But sometimes it will produce excess number of ticks. It won't work with negative numbers as well. The overal approach to the problem is ok but there is a better way to handle this. The algorithm you want to use will depend on what you really want to get. Below I'm presenting you my code which I used in my JS Ploting library. I've tested it and it always works (hopefully ;) ). Here are the major steps:

    • get global extremas xMin and xMax (inlucde all the plots you want to print in the algorithm )
    • calculate range between xMin and xMax
    • calculate the order of magnitude of your range
    • calculate tick size by dividing range by number of ticks minus one
    • this one is optional. If you want to have zero tick allways printed you use tick size to calculate number of positive and negative ticks. Total number of ticks will be their sum + 1 (the zero tick)
    • this one is not needed if you have zero tick allways printed. Calculate lower and upper bound but remember to center the plot

    Lets start. First the basic calculations

        var range = Math.abs(xMax - xMin); //both can be negative
        var rangeOrder = Math.floor(Math.log10(range)) - 1; 
        var power10 = Math.pow(10, rangeOrder);
        var maxRound = (xMax > 0) ? Math.ceil(xMax / power10) : Math.floor(xMax / power10);
        var minRound = (xMin < 0) ? Math.floor(xMin / power10) : Math.ceil(xMin / power10);
    

    I round minimum and maximum values to be 100% sure that my plot will cover all the data. It is also very important to floor log10 of range wheter or not it is negative and substract 1 later. Otherwise your algorithm won't work for numbers that are lesser than one.

        var fullRange = Math.abs(maxRound - minRound);
        var tickSize = Math.ceil(fullRange / (this.XTickCount - 1));
    
        //You can set nice looking ticks if you want
        //You can find exemplary method below 
        tickSize = this.NiceLookingTick(tickSize);
    
        //Here you can write a method to determine if you need zero tick
        //You can find exemplary method below
        var isZeroNeeded = this.HasZeroTick(maxRound, minRound, tickSize);
    

    I use "nice looking ticks" to avoid ticks like 7, 13, 17 etc. Method I use here is pretty simple. It is also nice to have zeroTick when needed. Plot looks much more professional this way. You will find all the methods at the end of this answer.

    Now you have to calculate upper and lower bounds. This is very easy with zero tick but requires a little bit more effort in other case. Why? Because we want to center the plot within upper and lower bound nicely. Have a look at my code. Some of the variables are defined outside of this scope and some of them are properties of an object in which whole presented code is kept.

        if (isZeroNeeded) {
    
            var positiveTicksCount = 0;
            var negativeTickCount = 0;
    
            if (maxRound != 0) {
    
                positiveTicksCount = Math.ceil(maxRound / tickSize);
                XUpperBound = tickSize * positiveTicksCount * power10;
            }
    
            if (minRound != 0) {
                negativeTickCount = Math.floor(minRound / tickSize);
                XLowerBound = tickSize * negativeTickCount * power10;
            }
    
            XTickRange = tickSize * power10;
            this.XTickCount = positiveTicksCount - negativeTickCount + 1;
        }
        else {
            var delta = (tickSize * (this.XTickCount - 1) - fullRange) / 2.0;
    
            if (delta % 1 == 0) {
                XUpperBound = maxRound + delta;
                XLowerBound = minRound - delta;
            }
            else {
                XUpperBound =  maxRound + Math.ceil(delta);
                XLowerBound =  minRound - Math.floor(delta);
            }
    
            XTickRange = tickSize * power10;
            XUpperBound = XUpperBound * power10;
            XLowerBound = XLowerBound * power10;
        }
    

    And here are methods I mentioned before which you can write by yourself but you can also use mine

    this.NiceLookingTick = function (tickSize) {
    
        var NiceArray = [1, 2, 2.5, 3, 4, 5, 10];
    
        var tickOrder = Math.floor(Math.log10(tickSize));
        var power10 = Math.pow(10, tickOrder);
        tickSize = tickSize / power10;
    
        var niceTick;
        var minDistance = 10;
        var index = 0;
    
        for (var i = 0; i < NiceArray.length; i++) {
            var dist = Math.abs(NiceArray[i] - tickSize);
            if (dist < minDistance) {
                minDistance = dist;
                index = i;
            }
        }
    
        return NiceArray[index] * power10;
    }
    
    this.HasZeroTick = function (maxRound, minRound, tickSize) {
    
        if (maxRound * minRound < 0)
        {
            return true;
        }
        else if (Math.abs(maxRound) < tickSize || Math.round(minRound) < tickSize) {
    
            return true;
        }
        else {
    
            return false;
        }
    }
    

    There is only one more thing that is not included here. This is the "nice looking bounds". These are lower bounds that are numbers similar to the numbers in "nice looking ticks". For example it is better to have the lower bound starting at 5 with tick size 5 than having a plot that starts at 6 with the same tick size. But this my fired I leave it to you.

    Hope it helps. Cheers!

    0 讨论(0)
  • 2020-12-07 09:08

    I'm still battling with this :)

    The original Gamecat answer does seem to work most of the time, but try plugging in say, "3 ticks" as the number of ticks required (for the same data values 15, 234, 140, 65, 90)....it seems to give a tick range of 73, which after dividing by 10^2 yields 0.73, which maps to 0.75, which gives a 'nice' tick range of 75.

    Then calculating upper bound: 75*round(1+234/75) = 300

    and the lower bound: 75 * round(15/75) = 0

    But clearly if you start at 0, and proceed in steps of 75 up to the upper bound of 300, you end up with 0,75,150,225,300 ....which is no doubt useful, but it's 4 ticks (not including 0) not the 3 ticks required.

    Just frustrating that it doesn't work 100% of the time....which could well be down to my mistake somewhere of course!

    0 讨论(0)
  • 2020-12-07 09:13

    Thanks for the question and answer, very helpful. Gamecat, I am wondering how you are determining what the tick range should be rounded to.

    tick range = 21.9. This should be 25.0

    To algorithmically do this, one would have to add logic to the algorithm above to make this scale nicely for larger numbers? For example with 10 ticks, if the range is 3346 then the tick range would evaluate to 334.6 and rounding to the nearest 10 would give 340 when 350 is probably nicer.

    What do you think?

    0 讨论(0)
提交回复
热议问题