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


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

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

Так что если данные точки являются:

   15, 234, 140, 65, 90

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

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

Так что там есть 10 (не включая 0), последний простирается чуть дальше самого высокого значения (234

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

девять было бы сложно. Может быть, просто использовали либо 8, либо 10 и назовите его близким достаточно было бы хорошо. А что делать, когда некоторые моменты отрицательные?

Я вижу, что Excel решает эту проблему красиво.

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

10 64

10 ответов:

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

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

давайте ваш пример:

15, 234, 140, 65, 90 with 10 ticks
  1. нижняя граница = 15
  2. верхняя граница = 234

вот пример 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
)

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

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

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

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

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

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

пока смещение не станет "хорошим"

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

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

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

и нижняя граница: Семьдесят пять * круглый(15/75) = 0

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

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

ответ 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);

я округляю минимальные и максимальные значения, чтобы быть на 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 с тем же размером тика. Но это мое увольнение я оставляю вам.

надеюсь, что это помогает. Ура!

это работает как шарм, если вы хотите 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

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

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

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

Что вы думаете?

основываясь на алгоритме @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);
    }
}

приведенные выше алгоритмы не учитывают случай, когда диапазон между минимальным и максимальным значением слишком мал. А что, если эти значения намного выше нуля? Тогда у нас есть возможность начать ось 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;
}