图表坐标轴的打勾算法

28

我正在寻找一种算法,用于在一个轴上放置刻度线,需要提供要显示的范围、要显示宽度以及测量刻度线字符串宽度的函数。

例如,假设需要显示1e-6到5e-6之间的数字,并提供一个要显示的像素宽度,该算法将确定应在1e-6、2e-6、3e-6、4e-6和5e-6处放置刻度线。当提供的宽度较小时,算法可能会决定最佳位置仅为偶数位置,即2e-6和4e-6(因为添加更多的刻度线会导致它们重叠)。

智能的算法会优先考虑10、5和2的倍数刻度线,并且在零点周围对称。

5个回答

15

由于我不喜欢到目前为止我找到的任何解决方案,所以我实现了自己的解决方案。它是用C#编写的,但可以很容易地翻译成任何其他语言。

它基本上从可能的步骤列表中选择最小的步骤,以显示所有值,而又不将任何一个值正好放在边缘处,让您轻松选择要使用的可能步骤(无需编辑丑陋的if-else if块),并支持任何值范围。 我使用了C# Tuple返回三个值,仅用于快速简单演示。

private static Tuple<decimal, decimal, decimal> GetScaleDetails(decimal min, decimal max)
{
    // Minimal increment to avoid round extreme values to be on the edge of the chart
    decimal epsilon = (max - min) / 1e6m;
    max += epsilon;
    min -= epsilon;
    decimal range = max - min;

    // Target number of values to be displayed on the Y axis (it may be less)
    int stepCount = 20;
    // First approximation
    decimal roughStep = range / (stepCount - 1);

    // Set best step for the range
    decimal[] goodNormalizedSteps = { 1, 1.5m, 2, 2.5m, 5, 7.5m, 10 }; // keep the 10 at the end
    // Or use these if you prefer:  { 1, 2, 5, 10 };

    // Normalize rough step to find the normalized one that fits best
    decimal stepPower = (decimal)Math.Pow(10, -Math.Floor(Math.Log10((double)Math.Abs(roughStep))));
    var normalizedStep = roughStep * stepPower;
    var goodNormalizedStep = goodNormalizedSteps.First(n => n >= normalizedStep);
    decimal step = goodNormalizedStep / stepPower;

    // Determine the scale limits based on the chosen step.
    decimal scaleMax = Math.Ceiling(max / step) * step;
    decimal scaleMin = Math.Floor(min / step) * step;

    return new Tuple<decimal, decimal, decimal>(scaleMin, scaleMax, step);
}

static void Main()
{
    // Dummy code to show a usage example.
    var minimumValue = data.Min();
    var maximumValue = data.Max();
    var results = GetScaleDetails(minimumValue, maximumValue);
    chart.YAxis.MinValue = results.Item1;
    chart.YAxis.MaxValue = results.Item2;
    chart.YAxis.Step = results.Item3;
}

嗨,我发现 int stepCount = 8; minimumValue = 0.33, maximumValue=0.55 这里出了问题,它返回的值是 {scaleMin: 0.3, scaleMax: 0.55, step: 0.05} 这意味着第六步是0.3+0.05*6 = 0.6,第七步是0.65,第八步是0.7:第6、7、8个数字大于了最大刻度: 0.55... - user2010955
1
我尝试过了,但返回的 scaleMax0.6。这避免了极端值恰好在轴限制上的情况。为了避免出现奇怪的步长或范围过大,妥协的办法是减少步数。请注意注释,其中写着“目标在 Y 轴上显示的值的数量 (可能更少)”。 如果您想要确切的 8 步数,您必须选择一个更丑陋的步长或者更宽的轴范围,而这种逻辑认为这些都是更糟糕的选项。 您可以尝试添加更多的值到 goodNormalizedSteps 中,例如 0.30.8 - Andrew
1
你需要将 goodNormalizedSteps 加上 3.15m3.2m 才能恰好达到8个步骤。 - Andrew
我发现另一个选项:添加 3.33,最好像这样:...2.5m, 10/3m, 5, ...。你将得到从 0.30.5666 的 8 个步骤。 - Andrew

2
取与零最接近的区间(如果零不在范围内,则取整个图)- 例如,如果您在范围[-5,1]上有某些内容,则取[-5,0]。
大约计算出这个区间将有多长,以tick为单位。这只是将长度除以刻度宽度。因此,假设该方法说我们可以从-5到0放入11个刻度。这是我们的上限。对于较短的一侧,我们将仅在较长的一侧镜像结果。
现在尝试放入尽可能多(最多11个)的刻度,使每个刻度的标记形式为i * 10 * 10 ^ n,i * 5 * 10 ^ n,i * 2 * 10 ^ n,其中n是整数,i是刻度索引。现在这是一个优化问题-我们想要最大化我们可以放置的刻度数,同时最小化最后一个刻度和结果末端之间的距离。因此,为尽可能获取刻度分配一个得分,该得分小于我们的上限,并为将最后一个刻度靠近n分配一个得分-您必须在此进行实验。
在上面的示例中,尝试n = 1.我们获得1个刻度(在i = 0处)。当n = 2时,我们获得1个刻度,并且离下限更远,因此我们知道我们必须朝另一个方向走。当n = 0时,我们获得了6个刻度,每个整数点一个。当n =-1时,我们获得12个刻度(0,-0.5,...,-5.0)。当n =-2时,我们获得24个刻度,以此类推。评分算法将为它们每个人分配一个得分-得分越高,方法越好。
对i * 5 * 10 ^ n和i * 2 * 10 ^ n再次执行此操作,并选择得分最高的那个。
(作为示例评分算法,假设得分是距离最后一个刻度的距离乘以最大刻度数减去所需数量。这可能会很差,但它将作为一个不错的起点)。

1
有趣的是,就在一周多之前,我来到这里寻找答案,但最终决定自己设计算法。我在此分享,如果有用的话。
我使用Python编写代码,以尽快找到解决方案,但它可以轻松地移植到任何其他语言。
以下函数计算给定数据范围的适当间隔(我允许间隔为10 ** n2 * 10 ** n4 * 10 ** n5 * 10 ** n),然后计算要放置刻度的位置(基于范围内可被间隔整除的数字)。我未使用模数%运算符,因为由于浮点算术舍入误差,它无法与浮点数正确工作。
代码:
import math


def get_tick_positions(data: list):
    if len(data) == 0:
        return []
    retpoints = []
    data_range = max(data) - min(data)
    lower_bound = min(data) - data_range/10
    upper_bound = max(data) + data_range/10
    view_range = upper_bound - lower_bound
    num = lower_bound
    n = math.floor(math.log10(view_range) - 1)
    interval = 10**n
    num_ticks = 1
    while num <= upper_bound:
        num += interval
        num_ticks += 1
        if num_ticks > 10:
            if interval == 10 ** n:
                interval = 2 * 10 ** n
            elif interval == 2 * 10 ** n:
                interval = 4 * 10 ** n
            elif interval == 4 * 10 ** n:
                interval = 5 * 10 ** n
            else:
                n += 1
                interval = 10 ** n
            num = lower_bound
            num_ticks = 1
    if view_range >= 10:
        copy_interval = interval
    else:
        if interval == 10 ** n:
            copy_interval = 1
        elif interval == 2 * 10 ** n:
            copy_interval = 2
        elif interval == 4 * 10 ** n:
            copy_interval = 4
        else:
            copy_interval = 5
    first_val = 0
    prev_val = 0
    times = 0
    temp_log = math.log10(interval)
    if math.isclose(lower_bound, 0):
        first_val = 0
    elif lower_bound < 0:
        if upper_bound < -2*interval:
            if n < 0:
                copy_ub = round(upper_bound*10**(abs(temp_log) + 1))
                times = copy_ub // round(interval*10**(abs(temp_log) + 1)) + 2
            else:
                times = upper_bound // round(interval) + 2
        while first_val >= lower_bound:
            prev_val = first_val
            first_val = times * copy_interval
            if n < 0:
                first_val *= (10**n)
            times -= 1
        first_val = prev_val
        times += 3
    else:
        if lower_bound > 2*interval:
            if n < 0:
                copy_ub = round(lower_bound*10**(abs(temp_log) + 1))
                times = copy_ub // round(interval*10**(abs(temp_log) + 1)) - 2
            else:
                times = lower_bound // round(interval) - 2
        while first_val < lower_bound:
            first_val = times*copy_interval
            if n < 0:
                first_val *= (10**n)
            times += 1
    if n < 0:
        retpoints.append(first_val)
    else:
        retpoints.append(round(first_val))
    val = first_val
    times = 1
    while val <= upper_bound:
        val = first_val + times * interval
        if n < 0:
            retpoints.append(val)
        else:
            retpoints.append(round(val))
        times += 1
    retpoints.pop()
    return retpoints

将以下三点数据传入函数时:

points = [-0.00493, -0.0003892, -0.00003292]

... 我得到的输出(作为列表)如下:

[-0.005,-0.004,-0.003,-0.002,-0.001,0.0]

将这个传入:

points = [1.399,38.23823,8309.33,112990.12]

... 我得到:

[0, 20000, 40000, 60000, 80000, 100000, 120000]

将这个传入:

points = [-54,-32,-19,-17,-13,-11,-8,-4,12,15,68]

... 我得到:

[-60,-40,-20,0,20,40,60,80]

... 这些似乎都是放置刻度的不错选择位置。

该函数允许5-10个刻度,但如果您愿意,可以轻松更改。

无论提供的数据列表是有序还是无序的,都没有关系,因为只有列表中的最小值和最大值才重要。


1

我一直在使用jQuery flot图形库。它是开源的,并且可以很好地生成坐标轴/刻度。我建议您查看它的代码并从中借鉴一些想法。


1
请不要在没有示例的情况下添加答案,因为简单的链接容易失效。然而,我不会给你负评,因为flot正是我所需要的! - Auspex

1
这个简单的算法可以得到一个是10的幂次方的1、2或5的倍数的区间。并且坐标轴范围至少被分成5个区间。代码示例使用Java语言:
protected double calculateInterval(double range) {
    double x = Math.pow(10.0, Math.floor(Math.log10(range)));
    if (range / x >= 5)
        return x;
    else if (range / (x / 2.0) >= 5)
        return x / 2.0;
    else
        return x / 5.0;
}

这是一个替代方案,最少需要10个间隔:

protected double calculateInterval(double range) {
    double x = Math.pow(10.0, Math.floor(Math.log10(range)));
    if (range / (x / 2.0) >= 10)
        return x / 2.0;
    else if (range / (x / 5.0) >= 10)
        return x / 5.0;
    else
        return x / 10.0;
}

到目前为止,这里最好的答案!非常简单,而且完美地运行。 - Elmue

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接