Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Smooth Zoom & Round Numbers in MS Chart

0.00/5 (No votes)
11 Oct 2018 4  
Overriding the quirky zoom in MS Chart, and scaling axes with nice round numbers

Introduction

As a scientist, I'm constantly plotting XY scatter data in simple line graphs. In such a graph, the horizontal axis (X) data is not indexed, meaning the spacing between X values can vary just like that in the Y values. Thirty years ago, I had to write my own graphics and charting routines, which was a lot of work. To my great relief, about five or six years ago, Microsoft released the first version of its Chart control, which is located in namespace:

System.Windows.Forms.DataVisualization.Charting

Unfortunately, it was sadly deficient in two specific areas:

  1. built-in zoom, and
  2. nice round numbers for selection and formatting of the axis labels

I've experimented with Microsoft's built-in zoom several times, and for the life of me, I can't follow the logic for the way it works. The zoom rectangle appears to snap to the nearest interval. Sometimes, it insists on covering the entire width or height of the chart, and when it does that, it only zooms in one dimension. So after considerable frustration, I abandoned it, and implemented my own.

When it comes to axis scaling and the grid lines displayed, if you leave it up to the chart control, you can get something like this:

which is absolutely dreadful.

In this article, I'm going to demonstrate smooth and intuitive zooming, and detail a simple algorithm for generating nice round numbers, given a range of values from minimum to maximum.

Using the Code

The code is implemented as a Visual Studio 2015 self-contained project. You should be able to download the code file, unzip it, load it into VS, and compile and execute it.

It is a Windows Form application with a sizable form that includes a single chart. On initialization, several sinusoids with exponential decay envelopes are added to the chart. It doesn't do anything other than to provide a means of demonstrating the techniques and algorithms discussed in this article.

To see how smooth zooming works, run the application, then, while holding the Ctrl key down, Left-Click in the plot and drag the zoom rectangle around an area of interest. When you release the mouse key, the chart will zoom into the area you selected. While dragging the lower right corner of the rectangle, it will look something like this:

After zooming in to the selected area, you can zoom in deeper as many times as you want. When you want to zoom back out, Right-Click on the chart, and select the menu item Zoom Out.

Adding a Smooth Zoom to a Chart

To add a more robust zoom capability to the charts, I defined some variables and hooked into the chart's MouseDown, MouseMove and MouseUp events in the following way:

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

The MouseDown event checks to ensure that the Ctrl key is pressed and that the event was initiated by a Left mouse click. It then initializes the zoom rectangle zoomRect and sets the Boolean zoomingNow. The key to smooth zooming is the DrawZoomRect method, which performs an XOR draw of a dashed rectangle:

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 Draw

The XOR draw function replaces each pixel with a bitwise XOR of the drawing pen and the original pixel color. A big advantage of this drawing method is that repeating the draw with the same rectangle erases the rectangle and restores the original pixel values. To understand how this works, let's review the XOR Boolean truth table:

Pen Bit Pixel Bit Result Bit
0 0 0
0 1 1
1 0 1
1 1 0

From the table, you can see that if the pen bit is 0, the result bit is preserved, but if the pen bit is 1, the result bit is inverted. So when drawing the same rectangle twice, preserved bits are preserved in both operations, and inverted bits are inverted twice, returning them to their original values.

That is the big advantage of XOR drawing: all of the colors in a complex image can be returned to their original state by simply drawing the rectangle again. Look again at the code in the MouseMove event. It performs the following actions:

  1. the previous rectangle is erased by redrawing it
  2. zoomRect is updated to the new size
  3. then the new rectangle is drawn

If the zoom rectangle is drawn with a simple black and white dashed pen, then to erase it, the entire rectangle must be invalidated and redrawn so all pixels are returned to their original state. Instead, the rectangle is XOR'd directly to the screen, which is much faster, and smoother, than repainting everything within the rectangle.

Furthermore, XOR drawing the rectangle with a black and white dashed pen, doesn't necessarily draw a black and white rectangle. The black portion of the dash is all 0s, so the pixel bits are preserved. The white part of the dash is all 1s, so the pixel's bits are inverted. For example, if the background in the chart is red (RGB = 255, 0, 0) the white portions of the pen invert that to aqua (RGB = 0, 255, 255). This example illustrates that:

To create this effect, I set the background color of the chart area to red. I also set the width of the XOR pen to 5 to make it more visible, which makes it paint as a solid line. Notice how the colors in the grid lines and plots are also inverted. Without the XOR drawing function, the code would have to keep track of the colors of a lot of pixels, or repaint the entire rectangle, both of which will slow things down.

Keep in mind that if the background is a mid-luminance color like gray (RGB = 128, 128, 128), inverting that produces a slightly different gray (RGB = 127, 127, 127), a color that for all intents and purposes is indistinguishable from the original. This is one limitation to this technique, though this can be overcome by simply setting the color of the XOR pen to that of the background.

gdi32.dll vs. C#'s DrawReversibleFrame

The XOR drawing function makes use of several methods in the Windows gdi32.dll library. The declarations for those functions, and the method GDI32.DrawXORRectangle, which is called within DrawZoomRect, are all contained within the code file GDI32.cs.

Microsoft does offer what appears to be a C# XOR drawing function in ControlPaint.DrawReversibleFrame. You can experiment with it by Right-Clicking on the plot area, then unchecking the menu item Zoom with GDI32. It works, but it flickers quite a bit when dragging the corner of the zoom rectangle, and I find that unacceptable. I suspect that the function is internally invalidating the entire rectangle and triggering the paint event, which would slow everything down.

Executing the Zoom

In the MouseUp event, the zoom rectangle is erased by redrawing it, the final zoomRect is defined, and ZoomInToZoomRect is called, which is coded as follows:

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 computes a rectangle that is the area contained within the portion of zoomRect that overlaps the chart innerPlotRectangle. It feeds those pixel bounds to SetZoomAxisScale which uses the chart's axis.PixelPositionToValue method to convert the pixel values to new minimums and maximums for each axis.

ChartScaleData is simply a container class used to store the baseline scale for the axes. Those values are used to zoom back out to the original settings.

Nice Round Numbers

If no effort is made to produce nice round numbers, we get something like the first image in this article. The algorithm I use here is one I developed several decades ago and is coded as follows:

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 begins by addressing the situation where min == max, in which case it simply sets up an arbitrary range. Next, the range (delta = max - min) occupied by the data is converted to the form:

delta = A x 10N,

where

1.0 ≤ A < 10.0

and N is an integer value. Once A is restricted to this range, we can use a look-up table in the form of arrays to determine nice round numbers for intervals. The arrays are coded as follows:

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

The array roundMantissa defines bins for the value of A, from which nice round values are provided for major and minor tick intervals. For example, the first bin is for A = 1.0, and from that, we get the interval values of 0.20 and 0.05. The second bin is for 1.0< A ≤ 1.2, and from that we again get the interval values of 0.20 and 0.05. The algorithm is easily customized by changing the bin and interval values in these arrays.

Finally, if the minimum doesn't fall on an interval boundary, it needs to be dropped down to one, and if the maximum doesn't fall on an interval boundary, it needs to pushed up to one. The C# Math.Floor and Math.Cieling methods are ideal for this and are used to get nice round numbers for the minimum and maximum.

Furthermore, if N < 0, then |delta| < 1.0, and -(N + 1) is equal to the number of 0s to the right of the decimal place before the first non-zero digit. And if N ≥ 0, then |delta| ≥ 1.0, and (N + 1) is equal to the number of significant digits to the left of the decimal place. So N can be used to add formatting to the axis labels.

Formatting Axis Labels

Now that we're specifying nice round numbers for maximums, minimums and intervals, the MS Chart form does a reasonably good job of selecting an axis label format. The image below is that of the sample application after zooming in a couple of levels, and allowing MS Chart to select default label formats:

MS Chart is careful to choose a format for each label so it is clearly delineated from the other labels on the axis. On the other hand, it might be more aesthetically pleasing if all the labels on an axis used the same format. As was suggested in one of the comments. That can be done in the following fashion:

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

However, in this case, hard-coding the label format in that way yields the following:

Clearly, a more flexible approach is needed. To accomplish that, we'll use the same algorithm for computing the exponent N when computing nice round numbers. I've recoded that as its own method in the following way:

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

To reiterate, Base10Exponent returns the integer exponent (N) that would yield a number of the form A x 10N, where 1.0 ≤ |A| < 10.0. But now we're going to apply it to the interval value and the maximum absolute value encountered in the axis range. That is done in the RangeFormatString method, which is coded in the following way:

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

maxE and minE have the property that (maxE - minE + 1) is the number of significant digits needed to distinguish two numbers spaced by interval. For example, if we're looking at numbers that can go as high as 5000, but we have zoomed into an interval of 0.001, then:

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

Hence, we need to display 7 significant digits to differentiate numbers like 4999.001 and 4999.002. Once that is determined, the if-else statement is merely an arbitrary decision on when to switch between fixed format for numbers of a reasonable size, and exponential format for numbers that are very large or very small. In this case, numbers A in the range 0.0001 ≤ |A| < 10,000 will be displayed in fixed format, and all others will be displayed in exponential format. RangeFormatString also includes the added variable xtraDigits to optionally include some extra significant digits, if so desired. Set it to zero otherwise. The result now looks like this:

The sample application is now coded to automatically use Smart Axis Label Formatting by incorporating RangeFormatString into execution of the zoom event when nice round numbers are enabled. But if you want to compare Smart vs. Hard vs. Default techniques, three menu items have been added to the right-click context menu so you can switch between them and experiment.

Note that smart axis formatting doesn't work well without nice round numbers.

Also note that this same technique can be used to distinguish between two numbers A1 and A2 by simply calling RangeFormatString in the following way:

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

Here, the interval is replaced by the absolute value of the difference between the two, and the result is a format string that will display the minimum number of significant digits required to differentiate the two numbers.

Conclusion

MS Chart is so full featured, it would be a shame to abandon it just because of a few nasty flaws. I hope this shows that those flaws can be corrected with a little thoughtful coding.

Things To Do

  • Tie MS Chart's built-in panning capability into the zoom functions

History

  • 2018.09.24: First implementation and publication
  • 2018.09.25: Corrected a minor error in the description of how the bins in roundMantissa are handled
  • 2018.10.11: Use the exponent N to format axis labels

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here