为图的 Y 轴选择一个有吸引力的线性尺度

我正在编写一些代码来在我们的软件中显示条形(或线形)图。一切都很好。让我困惑的是标记 Y 轴。

打电话的人可以告诉我他们想要多么精细的 Y 量表标签,但我似乎陷入了到底是什么标签他们在一个“有吸引力”的方式。我无法形容“有吸引力”,也许你也不能,但我们看到它时就知道了,对吧?

因此,如果数据点是:

   15, 234, 140, 65, 90

用户需要在 Y 轴上标注10个标签,然后用纸和铅笔做一点小小的修改,就可以得到:

  0, 25, 50, 75, 100, 125, 150, 175, 200, 225, 250

所以这里有10个(不包括0) ,最后一个刚好超过最高值(234 < 250) ,这是一个“不错的”增量,每个增量为25。如果他们要求8个标签,增加30个看起来不错:

  0, 30, 60, 90, 120, 150, 180, 210, 240

九个就很棘手了。也许只要用8或10,并称之为足够接近就可以了。当一些点是负的时候怎么办?

我可以看到 Excel 很好地解决了这个问题。

有人知道一个通用的算法(甚至一些蛮力也可以)来解决这个问题吗?我不需要动作很快,但应该看起来不错。

45584 次浏览

Sounds like the caller doesn't tell you the ranges it wants.

So you are free to changed the end points until you get it nicely divisible by your label count.

Let's define "nice". I would call nice if the labels are off by:

1. 2^n, for some integer n. eg. ..., .25, .5, 1, 2, 4, 8, 16, ...
2. 10^n, for some integer n. eg. ..., .01, .1, 1, 10, 100
3. n/5 == 0, for some positive integer n, eg, 5, 10, 15, 20, 25, ...
4. n/2 == 0, for some positive integer n, eg, 2, 4, 6, 8, 10, 12, 14, ...

Find the max and min of your data series. Let's call these points:

min_point and max_point.

Now all you need to do is find is 3 values:

- start_label, where start_label < min_point and start_label is an integer
- end_label, where end_label > max_point and end_label is an integer
- label_offset, where label_offset is "nice"

that fit the equation:

(end_label - start_label)/label_offset == label_count

There are probably many solutions, so just pick one. Most of the time I bet you can set

start_label to 0

so just try different integer

end_label

until the offset is "nice"

A long time ago I have written a graph module that covered this nicely. Digging in the grey mass gets the following:

  • Determine lower and upper bound of the data. (Beware of the special case where lower bound = upper bound!
  • Divide range into the required amount of ticks.
  • Round the tick range up into nice amounts.
  • Adjust the lower and upper bound accordingly.

Lets take your example:

15, 234, 140, 65, 90 with 10 ticks
  1. lower bound = 15
  2. upper bound = 234
  3. range = 234-15 = 219
  4. tick range = 21.9. This should be 25.0
  5. new lower bound = 25 * round(15/25) = 0
  6. new upper bound = 25 * round(1+235/25) = 250

So the range = 0,25,50,...,225,250

You can get the nice tick range with the following steps:

  1. divide by 10^x such that the result lies between 0.1 and 1.0 (including 0.1 excluding 1).
  2. translate accordingly:
    • 0.1 -> 0.1
    • <= 0.2 -> 0.2
    • <= 0.25 -> 0.25
    • <= 0.3 -> 0.3
    • <= 0.4 -> 0.4
    • <= 0.5 -> 0.5
    • <= 0.6 -> 0.6
    • <= 0.7 -> 0.7
    • <= 0.75 -> 0.75
    • <= 0.8 -> 0.8
    • <= 0.9 -> 0.9
    • <= 1.0 -> 1.0
  3. multiply by 10^x.

In this case, 21.9 is divided by 10^2 to get 0.219. This is <= 0.25 so we now have 0.25. Multiplied by 10^2 this gives 25.

Lets take a look at the same example with 8 ticks:

15, 234, 140, 65, 90 with 8 ticks
  1. lower bound = 15
  2. upper bound = 234
  3. range = 234-15 = 219
  4. tick range = 27.375
    1. Divide by 10^2 for 0.27375, translates to 0.3, which gives (multiplied by 10^2) 30.
  5. new lower bound = 30 * round(15/30) = 0
  6. new upper bound = 30 * round(1+235/30) = 240

Which give the result you requested ;-).

------ Added by KD ------

Here's code that achieves this algorithm without using lookup tables, etc...:

double range = ...;
int tickCount = ...;
double unroundedTickSize = range/(tickCount-1);
double x = Math.ceil(Math.log10(unroundedTickSize)-1);
double pow10x = Math.pow(10, x);
double roundedTickRange = Math.ceil(unroundedTickSize / pow10x) * pow10x;
return roundedTickRange;

Generally speaking, the number of ticks includes the bottom tick, so the actual y-axis segments are one less than the number of ticks.

Try this code. I've used it in a few charting scenarios and it works well. It's pretty fast too.

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


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


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


// promote the MSD to either 1, 2, or 5
if (magMsd > 5.0)
magMsd = 10.0f;
else if (magMsd > 2.0)
magMsd = 5.0f;
else if (magMsd > 1.0)
magMsd = 2.0f;


return magMsd*magPow;
}
}

Here is a PHP example I am using. This function returns an array of pretty Y axis values that encompass the min and max Y values passed in. Of course, this routine could also be used for X axis values.

It allows you to "suggest" how many ticks you might want, but the routine will return what looks good. I have added some sample data and shown the results for these.

#!/usr/bin/php -q
<?php


function makeYaxis($yMin, $yMax, $ticks = 10)
{
// This routine creates the Y axis values for a graph.
//
// Calculate Min amd Max graphical labels and graph
// increments.  The number of ticks defaults to
// 10 which is the SUGGESTED value.  Any tick value
// entered is used as a suggested value which is
// adjusted to be a 'pretty' value.
//
// Output will be an array of the Y axis values that
// encompass the Y values.
$result = array();
// If yMin and yMax are identical, then
// adjust the yMin and yMax values to actually
// make a graph. Also avoids division by zero errors.
if($yMin == $yMax)
{
$yMin = $yMin - 10;   // some small value
$yMax = $yMax + 10;   // some small value
}
// Determine Range
$range = $yMax - $yMin;
// Adjust ticks if needed
if($ticks < 2)
$ticks = 2;
else if($ticks > 2)
$ticks -= 2;
// Get raw step value
$tempStep = $range/$ticks;
// Calculate pretty step value
$mag = floor(log10($tempStep));
$magPow = pow(10,$mag);
$magMsd = (int)($tempStep/$magPow + 0.5);
$stepSize = $magMsd*$magPow;


// build Y label array.
// Lower and upper bounds calculations
$lb = $stepSize * floor($yMin/$stepSize);
$ub = $stepSize * ceil(($yMax/$stepSize));
// Build array
$val = $lb;
while(1)
{
$result[] = $val;
$val += $stepSize;
if($val > $ub)
break;
}
return $result;
}


// Create some sample data for demonstration purposes
$yMin = 60;
$yMax = 330;
$scale =  makeYaxis($yMin, $yMax);
print_r($scale);


$scale = makeYaxis($yMin, $yMax,5);
print_r($scale);


$yMin = 60847326;
$yMax = 73425330;
$scale =  makeYaxis($yMin, $yMax);
print_r($scale);
?>

Result output from sample data

# ./test1.php
Array
(
[0] => 60
[1] => 90
[2] => 120
[3] => 150
[4] => 180
[5] => 210
[6] => 240
[7] => 270
[8] => 300
[9] => 330
)


Array
(
[0] => 0
[1] => 90
[2] => 180
[3] => 270
[4] => 360
)


Array
(
[0] => 60000000
[1] => 62000000
[2] => 64000000
[3] => 66000000
[4] => 68000000
[5] => 70000000
[6] => 72000000
[7] => 74000000
)

this works like a charm, if you want 10 steps + zero

//get proper scale for y
$maximoyi_temp= max($institucion); //get max value from data array
for ($i=10; $i< $maximoyi_temp; $i=($i*10)) {
if (($divisor = ($maximoyi_temp / $i)) < 2) break; //get which divisor will give a number between 1-2
}
$factor_d = $maximoyi_temp / $i;
$factor_d = ceil($factor_d); //round up number to 2
$maximoyi = $factor_d * $i; //get new max value for y
if ( ($maximoyi/ $maximoyi_temp) > 2) $maximoyi = $maximoyi /2; //check if max value is too big, then split by 2

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!

Based on @Gamecat's algorithm, I produced the following helper class

public struct Interval
{
public readonly double Min, Max, TickRange;


public static Interval Find(double min, double max, int tickCount, double padding = 0.05)
{
double range = max - min;
max += range*padding;
min -= range*padding;


var attempts = new List<Interval>();
for (int i = tickCount; i > tickCount / 2; --i)
attempts.Add(new Interval(min, max, i));


return attempts.MinBy(a => a.Max - a.Min);
}


private Interval(double min, double max, int tickCount)
{
var candidates = (min <= 0 && max >= 0 && tickCount <= 8) ? new[] {2, 2.5, 3, 4, 5, 7.5, 10} : new[] {2, 2.5, 5, 10};


double unroundedTickSize = (max - min) / (tickCount - 1);
double x = Math.Ceiling(Math.Log10(unroundedTickSize) - 1);
double pow10X = Math.Pow(10, x);
TickRange = RoundUp(unroundedTickSize/pow10X, candidates) * pow10X;
Min = TickRange * Math.Floor(min / TickRange);
Max = TickRange * Math.Ceiling(max / TickRange);
}


// 1 < scaled <= 10
private static double RoundUp(double scaled, IEnumerable<double> candidates)
{
return candidates.First(candidate => scaled <= candidate);
}
}

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;
}

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!

Converted this answer as Swift 4

extension Int {


static func makeYaxis(yMin: Int, yMax: Int, ticks: Int = 10) -> [Int] {
var yMin = yMin
var yMax = yMax
var ticks = ticks
// This routine creates the Y axis values for a graph.
//
// Calculate Min amd Max graphical labels and graph
// increments.  The number of ticks defaults to
// 10 which is the SUGGESTED value.  Any tick value
// entered is used as a suggested value which is
// adjusted to be a 'pretty' value.
//
// Output will be an array of the Y axis values that
// encompass the Y values.
var result = [Int]()
// If yMin and yMax are identical, then
// adjust the yMin and yMax values to actually
// make a graph. Also avoids division by zero errors.
if yMin == yMax {
yMin -= ticks   // some small value
yMax += ticks   // some small value
}
// Determine Range
let range = yMax - yMin
// Adjust ticks if needed
if ticks < 2 { ticks = 2 }
else if ticks > 2 { ticks -= 2 }


// Get raw step value
let tempStep: CGFloat = CGFloat(range) / CGFloat(ticks)
// Calculate pretty step value
let mag = floor(log10(tempStep))
let magPow = pow(10,mag)
let magMsd = Int(tempStep / magPow + 0.5)
let stepSize = magMsd * Int(magPow)


// build Y label array.
// Lower and upper bounds calculations
let lb = stepSize * Int(yMin/stepSize)
let ub = stepSize * Int(ceil(CGFloat(yMax)/CGFloat(stepSize)))
// Build array
var val = lb
while true {
result.append(val)
val += stepSize
if val > ub { break }
}
return result
}


}

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.

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]

A demo of accepted answer

function tickEvery(range, ticks) {
return Math.ceil((range / ticks) / Math.pow(10, Math.ceil(Math.log10(range / ticks) - 1))) * Math.pow(10, Math.ceil(Math.log10(range / ticks) - 1));
}


function update() {
const range = document.querySelector("#range").value;
const ticks = document.querySelector("#ticks").value;
const result = tickEvery(range, ticks);
document.querySelector("#result").textContent = `With range ${range} and ${ticks} ticks, tick every ${result} for a total of ${Math.ceil(range / result)} ticks at ${new Array(Math.ceil(range / result)).fill(0).map((v, n) => Math.round(n * result)).join(", ")}`;
}


update();
<input id="range" min="1" max="10000" oninput="update()" style="width:100%" type="range" value="5000" width="40" />
<br/>
<input id="ticks" min="1" max="20" oninput="update()" type="range" style="width:100%" value="10" />
<p id="result" style="font-family:sans-serif"></p>