Выбор привлекательной линейной шкалы для оси Y Y

Я пишу немного кода для отображения графика (или строки) в нашем программном обеспечении. Все идет хорошо. То, что меня озадачило, обозначает обозначение оси Y.

Вызывающий может рассказать мне, насколько точно они хотят присвоить шкалу Y, но я, похоже, зациклен на том, что обозначать их "привлекательным" способом. Я не могу описать "привлекательный", и, вероятно, вы тоже не можете, но мы это знаем, когда видим это, верно?

Итак, если точки данных:

   15, 234, 140, 65, 90

И пользователь запрашивает 10 меток на оси Y, немного оторвавшись бумагой и карандашом:

  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 хорошо справляется с этой проблемой.

Кто-нибудь знает универсальный алгоритм (даже некоторые грубые силы в порядке) для решения этого? Мне не нужно делать это быстро, но это должно выглядеть хорошо.

Ответ 1

Давным-давно я написал графический модуль, который хорошо это освещал. При копании в серой массе получается следующее:

  • Определите нижнюю и верхнюю границу данных. (Остерегайтесь особого случая, когда нижняя граница = верхняя граница!
  • Разделите диапазон на требуемое количество тиков.
  • Округлите диапазон тиков в хорошие суммы.
  • Отрегулируйте нижнюю и верхнюю границу соответственно.

Давайте возьмем ваш пример:

15, 234, 140, 65, 90 with 10 ticks
  1. нижняя граница = 15
  2. верхняя граница = 234
  3. диапазон = 234-15 = 219
  4. диапазон тиков = 21,9. Это должно быть 25,0
  5. новая нижняя граница = 25 * раунд (15/25) = 0
  6. новая верхняя граница = 25 * раунд (1 + 235/25) = 250

Итак, диапазон = 0,25,50,..., 225,250

Вы можете получить хороший диапазон тиков с помощью следующих шагов:

  1. разделите на 10 ^ x так, чтобы результат лежал между 0,1 и 1,0 (включая 0,1, исключая 1).
  2. переведите соответственно:
    • 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. умножить на 10 ^ х.

В этом случае 21,9 делится на 10 ^ 2, чтобы получить 0,219. Это <= 0,25, так что теперь у нас есть 0,25. Умножить на 10 ^ 2 это дает 25.

Давайте посмотрим на тот же пример с 8 галочками:

15, 234, 140, 65, 90 with 8 ticks
  1. нижняя граница = 15
  2. верхняя граница = 234
  3. диапазон = 234-15 = 219
  4. диапазон тиков = 27,375
    1. Разделить на 10 ^ 2 для 0,27375, переводится как 0,3, что дает (умноженное на 10 ^ 2) 30.
  5. новая нижняя граница = 30 * раунд (15/30) = 0
  6. новая верхняя граница = 30 * раунд (1 + 235/30) = 240

Которые дают результат, который вы просили ;-).

------ Добавлено KD ------

Вот код, который реализует этот алгоритм без использования таблиц подстановки и т.д...:

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;

Вообще говоря, число тиков включает нижний тик, поэтому фактические сегменты оси Y на один меньше, чем количество тиков.

Ответ 2

Вот пример PHP, который я использую. Эта функция возвращает массив довольно Y-значений оси, которые охватывают значения min и max Y. Конечно, эта процедура также может использоваться для значений оси X.

Это позволяет вам "подсказать", сколько тиков вам может понадобиться, но процедура вернется что выглядит хорошо. Я добавил некоторые примеры данных и показал результаты для них.

#!/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);
?>

Результат вывода из данных образца

# ./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
)

Ответ 3

Попробуйте этот код. Я использовал его в нескольких графических сценариях, и он работает хорошо. Это тоже довольно быстро.

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

Ответ 4

Похоже, что вызывающий абонент не сообщает вам диапазоны, которые он хочет.

Таким образом, вы можете изменить конечные точки, пока не получите его красиво делимым по количеству ярлыков.

Определите "nice". Я бы назвал приятным, если метки отключены:

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, ...

Найдите максимальное и минимальное количество ваших рядов данных. Позвольте называть эти точки:

min_point and max_point.

Теперь вам нужно всего лишь найти 3 значения:

- 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"

которые соответствуют уравнению:

(end_label - start_label)/label_offset == label_count

Есть, вероятно, много решений, поэтому просто выберите один. В большинстве случаев, я уверен, вы можете установить

start_label to 0

поэтому просто попробуйте различное целое число

end_label

пока смещение не станет "приятным"

Ответ 5

Я все еще борюсь с этим:)

Первоначальный ответ Gamecat, похоже, работает большую часть времени, но попробуйте подключить, скажем, "3 тика", как нужно количество тиков (для тех же значений данных 15, 234, 140, 65, 90)... Кажется, дает диапазон тика 73, который после деления на 10 ^ 2 дает 0.73, что соответствует 0.75, что дает "хороший" диапазон тиков 75.

Затем вычисление верхней границы: 75 * круглый (1 + 234/75) = 300

и нижняя граница: 75 * round (15/75) = 0

Но ясно, что если вы начинаете с 0 и выполняете шаги от 75 до верхней границы 300, вы получаете 0,75,150,225,300 .... это, без сомнения, полезно, но это 4 тика (не включая 0) не требуется 3 тика.

Просто разочарование в том, что он не работает в 100% случаев... что вполне может быть, конечно, до моей ошибки!

Ответ 6

Ответ Toon Krijthe работает большую часть времени. Но иногда это приводит к избыточному количеству клещей. Он не будет работать и с отрицательными числами. Обоснованный подход к проблеме - это нормально, но есть лучший способ справиться с этим. Алгоритм, который вы хотите использовать, будет зависеть от того, что вы действительно хотите получить. Ниже я представляю вам свой код, который я использовал в своей библиотеке JS Ploting. Я тестировал его, и он всегда работает (надеюсь);). Вот основные шаги:

  • получить глобальные экстремумы xMin и xMax (inlucde все графики, которые вы хотите распечатать в алгоритме)
  • рассчитать диапазон между xMin и xMax
  • рассчитать порядок вашего диапазона.
  • рассчитать размер тика, разделив диапазон на количество тиков минус один
  • этот вариант является необязательным. Если вы хотите, чтобы нулевой тик был напечатан, вы используете размер галочки для расчета числа положительных и отрицательных тиков. Общее количество тиков будет их суммой + 1 (нулевой тик)
  • Это не нужно, если у вас есть нулевой тик. Рассчитайте нижнюю и верхнюю границы, но не забудьте сосредоточить график

Давайте начнем. Сначала основные расчеты

    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 круглые минимальные и максимальные значения должны быть на 100% уверены, что мой сюжет будет охватывать все данные. Также очень важно заполнить log10 диапазона, или не отрицательно, и вычесть 1 позже. В противном случае ваш алгоритм не будет работать для чисел, которые меньше одного.

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

Я использую "симпатичные тики", чтобы избежать тиков, таких как 7, 13, 17 и т.д. Метод, который я здесь использую, довольно прост. Также неплохо иметь zeroTick, когда это необходимо. Сюжет выглядит гораздо более профессионально. Вы найдете все методы в конце этого ответа.

Теперь вам нужно рассчитать верхнюю и нижнюю границы. Это очень легко с нулевым тиком, но в другом случае требуется немного больше усилий. Зачем? Потому что мы хотим сфокусировать сюжет в верхней и нижней границах. Посмотрите на мой код. Некоторые из переменных определены вне этой области, а некоторые из них являются свойствами объекта, в котором хранится весь представленный код.

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

И вот методы, о которых я упомянул, прежде чем вы можете написать сами, но вы также можете использовать мои

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

Есть еще одна вещь, которая здесь не включена. Это "красивые оценки". Это нижние границы, которые являются числами, близкими к цифрам в "симпатичных тиках". Например, лучше иметь нижнюю границу, начинающуюся с 5 с размером тика 5, чем с графиком, начинающимся с 6 с таким же размером галочки. Но это мое увольнение, я оставляю это вам.

Надеюсь, это поможет. Ура!

Ответ 7

это работает как шарм, если вы хотите 10 шагов + ноль

//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

Ответ 8

Спасибо за вопрос и ответ, очень полезно. Gamecat, мне интересно, как вы определяете, к какому типу следует округлить.

диапазон тиков = 21.9. Это должно быть 25.0

Чтобы алгоритмически это сделать, нужно добавить логику к вышеприведенному алгоритму, чтобы сделать этот масштаб красивым для больших чисел? Например, с 10 тиками, если диапазон равен 3346, тогда диапазон тиков будет оцениваться до 334,6, а округление до ближайшего 10 даст 340, когда 350, вероятно, будет более приятным.

Как вы думаете?

Ответ 9

На основе алгоритма @Gamecat я произвел следующий вспомогательный класс

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

Ответ 10

Вышеупомянутые алгоритмы не учитывают случай, когда диапазон между минимальным и максимальным значениями слишком мал. А что, если эти значения намного выше нуля? Тогда у нас есть возможность запустить ось y со значением, большим нуля. Кроме того, чтобы избежать того, чтобы наша линия была полностью на верхней или нижней стороне графика, мы должны дать ей "воздух дышать".

Чтобы охватить эти случаи, я написал (на PHP) приведенный выше код:

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

Ответ 11

Преобразовал этот ответ как 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
    }

}

Ответ 12

Для тех, кому это нужно в ES5 Javascript, немного боролись, но вот оно:

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

На основании превосходного ответа Toon Krijtje.