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