65.9K
CodeProject 正在变化。 阅读更多。
Home

MS Chart 的平滑缩放和圆整数

starIconstarIconstarIconstarIconstarIcon

5.00/5 (16投票s)

2018年9月25日

CPOL

11分钟阅读

viewsIcon

32000

downloadIcon

1614

重写 MS Chart 中古怪的缩放,并使用漂亮的舍入数字缩放轴

引言

作为一名科学家,我不断地在简单的折线图中绘制 XY 散点数据。在这样的图中,水平轴 (X) 数据没有索引,这意味着 X 值之间的间距可以像 Y 值中的间距一样变化。三十年前,我不得不编写自己的图形和图表例程,这是一项繁重的工作。令我欣慰的是,大约五六年前,Microsoft 发布了其 Chart 控件的第一个版本,该控件位于命名空间中

System.Windows.Forms.DataVisualization.Charting

不幸的是,它在两个特定领域存在严重缺陷

  1. 内置缩放,以及
  2. 用于选择和格式化轴标签的漂亮舍入数字

我多次尝试 Microsoft 的内置缩放功能,但我实在无法理解它的工作逻辑。缩放矩形似乎会捕捉到最近的间隔。有时,它坚持覆盖整个图表的宽度或高度,当它这样做时,它只在一个维度上进行缩放。因此,在经历了相当多的挫折后,我放弃了它,并实现了我自己的缩放功能。

当涉及到轴缩放和显示的网格线时,如果你把它留给图表控件,你可能会得到这样的结果

这简直是糟糕透顶。

在本文中,我将演示平滑直观的缩放,并详细介绍一种简单的算法,用于在给定最小值到最大值范围的情况下生成漂亮的舍入数字。

Using the Code

代码实现为一个 Visual Studio 2015 自包含项目。你应该能够下载代码文件,解压缩,加载到 VS 中,然后编译并执行它。

它是一个 Windows 窗体应用程序,带有一个可调整大小的窗体,其中包含一个图表。在初始化时,将几个具有指数衰减包络的正弦曲线添加到图表中。它除了提供演示本文讨论的技术和算法的方法之外,不执行任何其他操作。

要查看平滑缩放的工作原理,请运行应用程序,然后,按住 Ctrl 键,在绘图区域中单击鼠标左键并拖动缩放矩形以选择感兴趣的区域。当您释放鼠标键时,图表将放大到您选择的区域。拖动矩形右下角时,它将看起来像这样

放大到选定区域后,您可以根据需要多次深入放大。当您想要缩小时,右键单击图表,然后选择菜单项 Zoom Out

为图表添加平滑缩放

为了为图表添加更强大的缩放功能,我定义了一些变量并以下列方式挂钩到图表的 MouseDownMouseMove MouseUp 事件

//Variables to implement a dashed zoom rectangle when the
//mouse is dragged over a chart with the Ctrl key pressed
Rectangle zoomRect;         //The zoom rectangle
bool zoomingNow = false;    //Flag to indicate that we're dragging
                            //to define the zoom rectangle
//MouseDown, MouseMove and MouseUp handle creation and drawing of the Zoom Rectangle
private void chart_MouseDown(object sender, MouseEventArgs e)
{
    if (LicenseManager.UsageMode == LicenseUsageMode.Designtime)
        return;
    this.Focus();
    //Test for Ctrl + Left Single Click to start displaying selection box
    if ((e.Button == MouseButtons.Left) && (e.Clicks == 1) &&
            ((ModifierKeys & Keys.Control) != 0) && sender is Chart)
    {
        zoomingNow = true;
        zoomRect.Location = e.Location;
        zoomRect.Width = zoomRect.Height = 0;
        DrawZoomRect(); //Draw the new selection rect
    }
    this.Focus();
}

private void chart_MouseMove(object sender, MouseEventArgs e)
{
    if (zoomingNow)
    {
        DrawZoomRect(); //Redraw the old selection
                        //rect, which erases it
        zoomRect.Width = e.X - zoomRect.Left;
        zoomRect.Height = e.Y - zoomRect.Top;
        DrawZoomRect(); //Draw the new selection rect
    }
}

private void chart_MouseUp(object sender, MouseEventArgs e)
{
    if (zoomingNow && e.Button == MouseButtons.Left)
    {
        DrawZoomRect(); //Redraw the selection
                        //rect, which erases it
        if ((zoomRect.Width != 0) && (zoomRect.Height != 0))
        {
            //Just in case the selection was dragged from lower right to upper left
            zoomRect = new Rectangle(Math.Min(zoomRect.Left, zoomRect.Right),
                    Math.Min(zoomRect.Top, zoomRect.Bottom),
                    Math.Abs(zoomRect.Width),
                    Math.Abs(zoomRect.Height));
            ZoomInToZoomRect(); //no Shift so Zoom in.
        }
        zoomingNow = false;
    }
}

MouseDown 事件检查以确保按下了 Ctrl 键并且事件是由鼠标左键单击启动的。然后它初始化缩放矩形 zoomRect 并设置布尔值 zoomingNow。平滑缩放的关键是 DrawZoomRect 方法,该方法执行虚线矩形的 XOR 绘制

private void DrawZoomRect()
{
    Pen pen = new Pen(Color.Black, 1.0f);
    pen.DashStyle = System.Drawing.Drawing2D.DashStyle.Dot;
    if (useGDI32)
    {
        //This is so much smoother than ControlPaint.DrawReversibleFrame
        GDI32.DrawXORRectangle(chart1.CreateGraphics(), pen, zoomRect);
    }
    else
    {
        Rectangle screenRect = chart1.RectangleToScreen(zoomRect);
        ControlPaint.DrawReversibleFrame(screenRect, chart1.BackColor, FrameStyle.Dashed);
    }
}

异或绘制

XOR 绘图功能将每个像素替换为绘图笔和原始像素颜色的按位 XOR 结果。这种绘图方法的一个巨大优势是,用相同的矩形重复绘制会擦除矩形并恢复原始像素值。为了理解其工作原理,让我们回顾一下 XOR 布尔真值表

笔位 像素位 结果位
0 0 0
0 1 1
1 0 1
1 1 0

从表中可以看出,如果笔位为 0,则结果位保持不变,但如果笔位为 1,则结果位反转。因此,在绘制两次相同的矩形时,保留的位在两次操作中都保持不变,而反转的位被反转两次,将其恢复到原始值。

这就是异或绘制的巨大优势:**通过简单地再次绘制矩形,复杂图像中的所有颜色都可以恢复到原始状态**。再次查看 `MouseMove` 事件中的代码。它执行以下操作

  1. 通过重新绘制,擦除之前的矩形
  2. `zoomRect` 更新为新尺寸
  3. 然后绘制新矩形

如果缩放矩形是用简单的黑白虚线笔绘制的,那么要擦除它,必须使整个矩形无效并重新绘制,以便所有像素都恢复到原始状态。相反,矩形直接与屏幕进行异或,这比重绘矩形内的所有内容要快得多,也更平滑。

此外,用黑白虚线笔对矩形进行 XOR 绘制,不一定绘制出黑白矩形。虚线中的黑色部分全为 0,因此像素位得以保留。虚线中的白色部分全为 1,因此像素位反转。例如,如果图表中的背景是红色 (RGB = 255, 0, 0),那么笔的白色部分会将其反转为水绿色 (RGB = 0, 255, 255)。这个例子说明了

为了创建这种效果,我将图表区域的背景颜色设置为红色。我还将异或笔的宽度设置为 5,使其更明显,这使其绘制为实线。请注意网格线和绘图中的颜色是如何反转的。如果没有异或绘图功能,代码将不得不跟踪大量像素的颜色,或者重新绘制整个矩形,这两种情况都会减慢速度。

请记住,如果背景是中等亮度颜色,例如灰色 (RGB = 128, 128, 128),反转它会产生稍微不同的灰色 (RGB = 127, 127, 127),这种颜色在所有意图和目的上都与原始颜色无法区分。这是此技术的一个限制,尽管可以通过简单地将 XOR 笔的颜色设置为背景颜色来克服此限制。

gdi32.dll vs. C# 的 DrawReversibleFrame

XOR 绘图函数利用了 Windows `gdi32.dll` 库中的几个方法。这些函数的声明,以及在 `DrawZoomRect` 中调用的 `GDI32.DrawXORRectangle` 方法,都包含在 `GDI32.cs` 代码文件中。

Microsoft 确实在 `ControlPaint.DrawReversibleFrame` 中提供了一个看似 C# XOR 绘图函数。您可以通过在绘图区域上右键单击,然后取消选中菜单项使用 GDI32 缩放来尝试它。它有效,但在拖动缩放矩形的角落时会闪烁很多,我觉得这是不可接受的。我怀疑该函数在内部使整个矩形无效并触发绘制事件,这会使所有操作变慢。

执行缩放

MouseUp 事件中,通过重新绘制来擦除缩放矩形,定义最终的 zoomRect,并调用 ZoomInToZoomRect,其代码如下

private void ZoomInToZoomRect()
{
    if (zoomRect.Width == 0 || zoomRect.Height == 0)
        return;

    Rectangle r = zoomRect;

    ChartScaleData csd = chart1.Tag as ChartScaleData;
    //Get overlap of zoomRect and the innerPlotRectangle
    Rectangle ipr = csd.innerPlotRectangle;
    if (!r.IntersectsWith(ipr))
        return;
    r.Intersect(ipr);
    if (!csd.isZoomed)
    {
        csd.isZoomed = true;
        csd.UpdateAxisBaseData();
    }

    SetZoomAxisScale(chart1.ChartAreas[0].AxisX, r.Left, r.Right);
    SetZoomAxisScale(chart1.ChartAreas[0].AxisY, r.Bottom, r.Top);
}

ZoomInToZoomRect 计算一个矩形,该矩形是 zoomRect 与图表 innerPlotRectangle 重叠部分所包含的区域。它将这些像素边界提供给 SetZoomAxisScale,后者使用图表的 axis.PixelPositionToValue 方法将像素值转换为每个轴的新最小值和最大值。

ChartScaleData 只是一个容器类,用于存储轴的基线比例。这些值用于缩回原始设置。

漂亮的整数

如果不努力生成漂亮的整数,我们就会得到本文第一张图片所示的效果。我在这里使用的算法是我几十年前开发的,代码如下

private void GetNiceRoundNumbers(ref double minValue, 
        ref double maxValue, 
        ref double interval, 
        ref double intMinor)
{
    double min = Math.Min(minValue, maxValue);
    double max = Math.Max(minValue, maxValue);
    double delta = max - min; //The full range
    //Special handling for zero full range
    if (delta == 0)
    {
        //When min == max == 0, choose arbitrary range of 0 - 1
        if (min == 0)
        {
            minValue = 0;
            maxValue = 1;
            interval = 0.2;
            intMinor = 0.5;
            return;
        }
        //min == max, but not zero, so set one to zero
        if (min < 0)
            max = 0; //min-max are -|min| to 0
        else
            min = 0; //min-max are 0 to +|max|
        delta = max - min;
    }

    double logDel = Math.Log10(delta);
    int N = Convert.ToInt32(Math.Floor(logDel));
    double tenToN = Math.Pow(10, N);
    double A = delta / tenToN;
    //At this point maxValue = A x 10^N, where
    // 1.0 <= A < 10.0 and N = integer exponent value
    //Now, based on A select a nice round interval and maximum value
    for (int i = 0; i < roundMantissa.Length; i++)
        if (A <= roundMantissa[i])
        {
            interval = roundInterval[i] * tenToN;
            intMinor = roundIntMinor[i] * tenToN;
            break;
        }
    minValue = interval * Math.Floor(min / interval);
    maxValue = interval * Math.Ceiling(max / interval);
}

GetNiceRoundNumbers 首先处理 min == max 的情况,在这种情况下,它只是设置一个任意范围。接下来,数据占据的范围 (delta = max - min) 转换为以下形式

delta = A x 10N

其中

1.0 ≤ A < 10.0

N 是一个整数值。一旦 **A** 被限制在这个范围内,我们就可以使用数组形式的查找表来确定间隔的漂亮整数。数组代码如下

double[] roundMantissa = { 1.00d, 1.20d, 1.40d, 1.60d, 1.80d, 2.00d, 
                           2.50d, 3.00d, 4.00d, 5.00d, 6.00d, 8.00d, 10.00d };
double[] roundInterval = { 0.20d, 0.20d, 0.20d, 0.20d, 0.20d, 0.50d, 
                           0.50d, 0.50d, 0.50d, 1.00d, 1.00d, 2.00d, 2.00d };
double[] roundIntMinor = { 0.05d, 0.05d, 0.05d, 0.05d, 0.05d, 0.10d, 0.10d, 
                           0.10d, 0.10d, 0.20d, 0.20d, 0.50d, 0.50d };

数组 `roundMantissa` 定义了 **A** 值的区间,从中可以为主要和次要刻度间隔提供漂亮的舍入值。例如,第一个区间用于 A = 1.0,从中我们得到间隔值 0.20 和 0.05。第二个区间用于 1.0 < A ≤ 1.2,从中我们再次得到间隔值 0.20 和 0.05。通过更改这些数组中的区间和间隔值,可以轻松自定义该算法。

最后,如果最小值没有落在区间边界上,则需要将其向下舍入到边界上,如果最大值没有落在区间边界上,则需要将其向上舍入到边界上。C# 的 Math.FloorMath.Ceiling 方法非常适合此目的,并用于获取最小值和最大值的漂亮舍入数字。

此外,如果 N < 0,则 |delta| < 1.0,并且 -(N + 1) 等于小数点右边第一个非零数字之前的零的数量。如果 N ≥ 0,则 |delta| ≥ 1.0,并且 (N + 1) 等于小数点左边有效数字的数量。因此,N 可以用于为轴标签添加格式。

格式化轴标签

既然我们为最大值、最小值和间隔指定了漂亮的整数,MS Chart 窗体在选择轴标签格式方面做得相当不错。下图是样本应用程序放大几级后,MS Chart 选择默认标签格式的示例

MS Chart 小心地为每个标签选择一种格式,使其与轴上的其他标签清晰地划分开来。另一方面,如果轴上的所有标签都使用相同的格式,可能会更美观。正如其中一条评论中所建议的那样。这可以通过以下方式完成

chart1.ChartAreas[0].AxisY.LabelStyle.Format = "F0";

然而,在这种情况下,以这种方式硬编码标签格式会产生以下结果

显然,需要一种更灵活的方法。为此,我们将使用相同的算法计算指数 **N**,就像计算漂亮的舍入数字一样。我已经将其重新编码为自己的方法,如下所示

public int Base10Exponent(double num)
{
    if (num == 0)
        return -Int32.MaxValue;
    else
        return Convert.ToInt32(Math.Floor(Math.Log10(Math.Abs(num))));
}

重申一下,`Base10Exponent` 返回整数指数 (N),该指数将生成 A x 10N 形式的数字,其中 1.0 ≤ |A| < 10.0。但现在我们将其应用于 `interval` 值和轴范围中遇到的最大绝对值。这在 `RangeFormatString` 方法中完成,其代码如下

public string RangeFormatString(double interval, double minVal, double maxVal, int xtraDigits)
{
    double maxAbsVal = Math.Max(Math.Abs(minVal), Math.Abs(maxVal));
    int minE = Base10Exponent(interval); //precision to which must show decimal
    int maxE = Base10Exponent(maxAbsVal);
    //(maxE - minE + 1) is the number of significant
    //digits needed to distinguish two numbers spaced by "interval"
    if (maxE < -4 || 3 < maxE)
        //"Exx" format displays 1 digit to the left of the decimal place, and xx
        //digits to the right of the decimal place, so xx = maxE - minE.
        return "E" + (xtraDigits + maxE - minE).ToString();
    else
        //In fixed format, since all digits to the left of the decimal place are
        //displayed by default, for "Fxx" format, xx = -minE or zero, whichever is greater.
        return "F" + xtraDigits + Math.Max(0, -minE).ToString();
}

maxEminE 具有这样的属性:(maxE - minE + 1) 是区分间隔为 interval 的两个数字所需的有效位数。例如,如果我们要查看最高可达 5000 的数字,但我们已将缩放调到 0.001 的间隔,则

maxE = 3
minE = -3
maxE - minE + 1 = 7

因此,我们需要显示 7 位有效数字来区分 4999.001 和 4999.002 等数字。一旦确定了这一点,`if-else` 语句就只是一个任意决定,决定何时在合理大小的数字的固定格式和非常大或非常小的数字的指数格式之间切换。在这种情况下,范围在 0.0001 ≤ |A| < 10,000 的数字将以固定格式显示,所有其他数字将以指数格式显示。`RangeFormatString` 还包含额外的变量 `xtraDigits`,如果需要,可以选择包含一些额外的有效数字。否则将其设置为零。结果现在看起来像这样

示例应用程序现在已编码为通过在启用漂亮整数时将 `RangeFormatString` 合并到缩放事件的执行中来自动使用智能轴标签格式化。但是,如果您想比较智能与硬编码与默认技术,右键单击上下文菜单中已添加了三个菜单项,以便您可以在它们之间切换和实验。

请注意,智能轴格式化在没有漂亮的整数时效果不佳。

另请注意,这种相同的技术可以通过以下方式简单地调用 `RangeFormatString` 来区分两个数字 **A1** 和 **A2**

string fmt = RangeFormatString(Math.Abs(A1 - A2), A1, A2, 0);

在这里,间隔被替换为两者之间差异的绝对值,结果是一个格式字符串,它将显示区分这两个数字所需的最小有效位数。

结论

MS Chart 功能如此齐全,仅仅因为一些令人不快的缺陷就放弃它将是一种耻辱。我希望这表明这些缺陷可以通过一些深思熟虑的编码来纠正。

待办事项

  • 将 MS Chart 的内置平移功能与缩放功能结合起来

历史

  • 2018.09.24:首次实现并发布
  • 2018.09.25:修正了 `roundMantissa` 中箱子处理方式描述中的一个微小错误
  • 2018.10.11:使用指数 N 格式化轴标签
© . All rights reserved.