## 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:

- built-in zoom, and
- 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:

Rectangle zoomRect;
bool zoomingNow = false;
private void chart_MouseDown(object sender, MouseEventArgs e)
{
if (LicenseManager.UsageMode == LicenseUsageMode.Designtime)
return;
this.Focus();
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();
}
this.Focus();
}
private void chart_MouseMove(object sender, MouseEventArgs e)
{
if (zoomingNow)
{
DrawZoomRect();
zoomRect.Width = e.X - zoomRect.Left;
zoomRect.Height = e.Y - zoomRect.Top;
DrawZoomRect();
}
}
private void chart_MouseUp(object sender, MouseEventArgs e)
{
if (zoomingNow && e.Button == MouseButtons.Left)
{
DrawZoomRect();
if ((zoomRect.Width != 0) && (zoomRect.Height != 0))
{
zoomRect = new Rectangle(Math.Min(zoomRect.Left, zoomRect.Right),
Math.Min(zoomRect.Top, zoomRect.Bottom),
Math.Abs(zoomRect.Width),
Math.Abs(zoomRect.Height));
ZoomInToZoomRect();
}
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)
{
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:

- the previous rectangle is erased by redrawing it
`zoomRect `

is updated to the new size - 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;
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;
if (delta == 0)
{
if (min == 0)
{
minValue = 0;
maxValue = 1;
interval = 0.2;
intMinor = 0.5;
return;
}
if (min < 0)
max = 0;
else
min = 0;
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;
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 10^{N},

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 10^{N}, 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);
int maxE = Base10Exponent(maxAbsVal);
if (maxE < -4 || 3 < maxE)
return "E" + (xtraDigits + maxE - minE).ToString();
else
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