Build a Reusable Graphical Charting Engine with C#

Build a Reusable Graphical Charting Engine with C#

t’s not every day you need to need to draw a chart, but it happens just often enough for you to wish there were some standard System.Drawing object to help you. By “chart” I mean nothing more sophisticated than a graphical rendering of a set of points with a scale and a pair of labelled axes.

I have written chart-like objects to fulfill particular needs before, but had never ended up reusing them, primarily because either the drawing logic was too specialized to be of general use, or because the data source it visualized was too specialized to describe other, more generic kinds of data. My dilemma, therefore, had a fairly obvious solution: Develop a Chart class that decouples its drawing logic from the data it visualizes; use as simple a data format as possible, and expose useful properties, so other classes can perform custom rendering if required.

Designing the Class
A chart needn’t be complicated. At heart, it should simply encapsulate the transformation between two kinds of coordinate space?the “data space” of a set of data, and the “screen space” where you decide to render that data. Although screen coordinates are usually expressed as integers, that’s simply a convention; the Chart class should assume that both data and screen space can contain fractional intervals, which it can then transform easily from one to the other like this:

   PointF Transform(RectangleF from, RectangleF to,       PointF pt_src)   {      PointF pt_dst = new PointF();      pt_dst.X = (((pt_src.X - from.Left) / from.Width)          * to.Width) + to.Left;      pt_dst.Y = (((pt_src.Y - from.Top) / from.Height)          * to.Height) + to.Top;      return pt_dst;   }

In the preceding code, the arguments from and to refer to the source and destination spaces respectively, and pt_src identifies a point in the source space to be transformed. This is more elegant than writing paired DataToScreen() and ScreenToData() methods whose transformations are simply inversions of one another. Points are not the only things you need to transform, though. You also need to be able to transform extents:

   SizeF Transform(RectangleF from, RectangleF to,       SizeF sz_src)   {      SizeF sz_dst = new SizeF();      sz_dst.Width = (from.Width / to.Width) *          sz_src.Width;      sz_dst.Height = (from.Height / to.Height) *          sz_src.Height;      return sz_dst;   }

The code in Listing 1 starts putting these ideas together. It generates fifty random points, using different scales for the X and Y axes, and scales and translates the data to fill a predefined screen rectangle with a 20-pixel border (see Figure 1). The code is straightforward, but one of its data structures has a few idiosyncrasies. Sidebar 1 discusses some points worth bearing in mind when using the System.Drawing primitives.

Figure 1. Random Points: This chart shows a starting point for graphing fifty random points, but doesn’t have any axes or labels.

Limitations of GDI+
If you’re familiar with the System.Drawing namespace, you might well be wondering: Why would anyone write custom code to transform between data space and screen space when there are perfectly good GDI+ methods that seemingly handle the same tasks?

Good question. GDI+ provides considerable support for transforming between different coordinate systems. When you draw onto a Graphics object, it uses the current Graphics.Transform matrix to apply any transformations you may have specified. By default, GDI+ uses the identity matrix, but you can explicitly Translate(), Scale() and Rotate() that matrix. You can even set its members yourself (for example, to achieve a shear effect). Furthermore, because it’s a matrix, there’s nothing to stop you from creating a pipeline of transformations and applying them successively.

Listing 2 draws a graph of arbitrary point data entirely in data space, using the GDI to scale and translate it so that it fits within prescribed margins (you don’t want the graph to fill the entire available area because you still need to draw the axes, axis titles and division markers). GDI+ certainly makes things simpler when you’re doing all your drawing in data units rather than screen units?and, if you’re sure this is what you want, GDI+ transformations are the way to go.

However, if you need to mix and match your drawing primitives and the spaces in which they are rendered, GDI+ transformations aren’t the way to go. The following code fragment gives a clue as to why:

   // Create the pen with a 1 pixel width   Pen pen = new Pen(control.ForeColor,       m_data_rect.Width /             main_rect.Width);

By default, a pen has a width of one pixel. But when you transform the drawing space, it affects the coordinate system of all the drawing primitives, not just the way that some particular point gets transformed into screen space. It’s as if you physically stuck a magnifying glass in front of the window. So you must selectively rescale Pens, Fonts and other GDI objects if you want to combine a scaled data space with unscaled legends. As you’ll see, a useful chart should be able to draw (in addition to its data) a pair of axes with some kind of scale along them. Typically, each major division along the x-axis scale should be annotated with a value, and that value should be centred horizontally and displayed a few pixels below the division in question. If you transform everything into data space, you would need (at a minimum) to:

  1. Create a font to draw the division value, and scale it back into screen space (so the font is a sensible size)
  2. Calculate the width of the division annotation string in screen space
  3. Calculate where to draw the division marker in screen space, and offset it by half the width of the string
  4. Translate this back into data space
  5. Calculate the y offset for the string (“a few pixels”) in data space
  6. Draw the string at the appropriate data space coordinates

Such needless complexity is a fairly compelling argument against drawing everything in data space using the GDI’s ready-rolled transformations, but there are two even better reasons not to use the GDI transformations. First, you won’t get what you want. Second, it’s hit and miss whether they’ll work at all. Here’s why.

Suppose your data’s x-axis scaling differs from its y-axis scaling (that’s very common in charts). If you draw in data space, any fonts you create will be similarly scaled. When you create a font, you can specify its point size, but you can’t control its aspect ratio. True, the Font object has thirteen constructors, and, I must confess, I should have thought at least one of them might provide a workaround, but I’ve yet to find it. You can expect worse than badly proportioned fonts, though. You may not get a font at all:

   Graphics g = CreateGraphics();   Brush brush = new SolidBrush(Color.Red);                  try      {      // With equal scaling of 750,000:1,       // the GDI doesn't complain      g.ScaleTransform((float)750000.0,         (float)750000.0);      Font font = new Font("Arial", (float)0.000093);      g.DrawString("Hi there", font, brush,          new PointF(0,0));                  // But with unequal scaling, the GDI can't cope      g.ResetTransform();      g.ScaleTransform((float)750000.0, (float)1);      font = new Font("Arial", (float)0.0000093);      g.DrawString("Hi there", font, brush,          new PointF(0,0));         g.ResetTransform();      g.ScaleTransform((float)1, (float)750000.0);      font = new Font("Arial", (float)0.0000093);      g.DrawString("Hi there", font, brush,          new PointF(0,0));      }      catch(System.Runtime.InteropServices.         ExternalException ec)      {      // That was one unhappy object      MessageBox.Show(ec.Message);      }

If you run the preceding code, you’ll find that the first string draws without problems, but you get an exception when the GDI tries to draw either the second or third strings. The code illustrates that scaling the data space doesn’t in itself create a problem. Even when scaling by a factor of 750,000:1, the GDI still correctly renders a string with a specified font size. But the code also illustrates that when the data space is scaled unevenly, there’s no simple way to determine how the scaling affects the chosen font, or what point setting might compensate for it. The moral is that GDI transforms have their uses, but think carefully before you do use them. Sometimes doing things yourself?may be the only way to get the job done.

Adding Axis Scales
Because charts tend to depend on transformations between different coordinate spaces, it’s imperative that they include information to help explain what they’re visualizing. At a minimum, each axis should display at least two reference values and a divided scale, so readers can make a visual estimate of the data range. Initially, I thought it would be enough simply to divide each axis into ten intervals and label each accordingly, but I soon realized that such a naive approach was unacceptable for a variety of reasons

First, having a nice round number of intervals isn’t always helpful or even sensible. For example, if you divide the range 1.72 to 7.27 into ten intervals, each interval ends up as 0.555, which is neither a sensible nor intuitive interval for users (see Figure 2).

Figure 2. Unintuitive Axes: If you determine your major intervals by simply dividing up the data range into a fixed number of segments, you are likely to end up with strange intervals.

Secondly, strings representing axis reference values may run to many decimal places, and accordingly take up large amounts of screen space. Unfortunately, rounding them to an appropriate number of significant digits is non trivial, because the level of significance required depends completely on the range of data being visualized. For example, you can easily round data between 1.72 to 7.27 to two decimal places, but if you apply the same rounding to data between 0.00172 and 0.00727, your reference values would be meaningless (see Figure 3).

Figure 3. Round Axis Divisions: If you don’t explicitly specify a rounding scheme, you end up with axes like these. In this case, the values along the x-axis should clearly be rounded to a single decimal place.

Instead, charts typically use nice round intervals like 1000 or 0.1 or 0.01, both because they’re easy to interpolate mentally, and because they occupy minimal screen space for their particular precision. But what if the data range doesn’t cross a neat “power of 10” boundary? For example, given a data range from 57 to 63, you’d expect to see not ten, but seven numbered intervals?from 57 to 63 inclusive. Because of these complications, the sample code contains the method CalcDivIntervals(), which performs common work for the two methods that actually draw the axes, DrawXAxis() and DrawYAxis() (see Listing 3). I must confess that CalcDivIntervals() isn’t the most intuitive piece of code, but it seems to do the job. I’ve marked it “virtual”, so you can override it to draw axis scales to specific requirements. At its heart is the line:

   // Calculate a suitable exponent for the    // division marker based on    // the magnitude of the data range   int mag = Math.Abs(((int) Math.Log10(1 / ax_size))       +1);

This code fragment determines what the major scale interval will be, based on the magnitude of the data range being displayed. For example, if the total range is 70,000, mag would receive a value of 4, meaning that major intervals should be displayed at 10 to the power of 4?or every 10,000 units. It would be nice if that were the end of the matter, and you could just draw in the major intervals right away, but you must also take into account the data range’s offset. While the range might be 70,000, that doesn’t necessarily mean the range is actually located between 0 and 70,000; for example, it might be between 30,000 and 100,000 (see Figure 4).

Figure 4. Invalid Data Range Offset: This figure illustrates calculating the axes using the correct range but the wrong offset. The charting engine has chosen appropriate major divisions for the two axes, but unhelpfully calculated the range relative to the graph’s origin.

Accordingly, you need to round the low end of the range (let’s call it min) to the nearest major interval, and then work your way back toward min by successive tenths of a major interval. This way, the chart engine draws divisions along the full length of the axis, and you can use the minor divisions as a guide when the axis contains only a few major divisions. Before doing that, though, you should check to see how many major divisions would be displayed, and adjust major_div accordingly, increasing its size if there are more than ten divisions, and reducing it if there are fewer than three, or you’ll end up with something like Figure 5. These bounds are largely subjective, so feel free to adjust them accordingly. I chose them simply because they seemed to work well with all the test data I could throw at them.

Figure 5. Too Many Divisions: Having ten major divisions isn’t always the correct solution, as this chart shows. Instead, you should adjust divisions based on the number of values that will actually display.

The actual axis rendering code merits little comment. Aside from a bit of color information, it really needs to know only two things?the data rectangle it should visualize (m_data_rect), and the screen rectangle in which to render it (m_screen_rect). It looks better if you center the scale’s reference values around the axis divisions they annotate, so, after converting the relevant values into strings, measure them and offset them accordingly. Because you’re calculating the string’s dimensions anyway, you may as well check if the string you are about to draw intersects the last string you drew. That way, you can draw a reference value only when there’s enough surrounding space to set it apart from its neighbouring values, which avoids situations like Figure 6. This simple idea works particularly well when you’re rendering the chart in a resizable window (try the sample project to see it in action).

Figure 6. Overwritten Axis Values: Even with a sensible major division and offset, and a suitable number of major divisions, things can still get untidy when you try to shrink a chart too far.

Zooming and Browsing the Chart
At this point, you can draw a chart of sorts, but, as it stands, it isn’t particularly useful unless your data is distributed uniformly and you have a pressing need to visualize the full sample set simultaneously. Think about it. Suppose most of your data lies in the range 0.1 to 0.15, with outlying elements of -1,000 and +1,000. At the default zoom (where the entire data set just fits on the chart), your data range of particular interest would occupy only fifteen thousandths of the available space on the axis. At that size, you probably wouldn’t be able to see anything meaningful. Therefore, the chart component needs some kind of zoom capability so people can identify that level of detail.

In fact, it needs two things?the ability to magnify an area of interest, and the ability to browse around that area to see how it connects with the rest of the chart. Fortunately, both are fairly trivial to implement. Because m_data_rect is a data-to-view transformation, zooming in on the data by ten percent is as simple as deflating that rectangle by ten percent. Similarly, all you need to do to browse across the data is to adjust that rectangle’s origin. So, to magnify or zoom the chart, you can simply write:

   public void Zoom(float percent)   {      float dx = (percent / 200) * m_data_rect.Width;      float dy = (percent / 200) * m_data_rect.Height;         m_data_rect = new RectangleF(         m_data_rect.Left + dx,         m_data_rect.Top + dy,         m_data_rect.Width - (dx*2),         m_data_rect.Height - (dy*2));   }

That magnifies the chart, but you probably won’t always want to zoom the data in the center of the chart, you want to “magnify an area of interest.” Sure, you can manually zoom in and then move around to view the portion you’re interested in, but that would soon become tedious. To eliminate this problem the companion method Snap()centers the view about a specific point. You can use Snap() by itself to move around the chart, or you can combine it with Zoom() to give the appearance of zooming into a particular location. Normally, you would achieve this effect by first translating your rectangle to the origin of your coordinate system, then applying the zoom, and finally translating the rectangle back again. But because Zoom() always transforms about the rectangle’s center, you need only perform the initial translation:

   public void Snap(PointF pt)   {      // Correct for inverted y axis      pt.Y = m_screen_rect.Bottom - pt.Y + m_margin;          // Transform into data coordinates      PointF origin = Transform(         m_screen_rect, m_data_rect, pt);            // Build a new rectangle from scratch      float min_x, max_x, min_y, max_y;         min_x = origin.X - (m_data_rect.Width /2);      max_x = origin.X + (m_data_rect.Width /2);      min_y = origin.Y - (m_data_rect.Height /2);      max_y = origin.Y + (m_data_rect.Height /2);      m_data_rect = new RectangleF(min_x, min_y,          m_data_rect.Width,          m_data_rect.Height);   }

Snap() takes a point in screen coordinates, because that’s how you’d expect to use it. With a chart visible on the screen, users can click somewhere with the mouse. You pass the click point to Snap() and it takes care of the rest.

Supporting zoom does have a side-effect. Before adding zoom capability, you always knew just how big m_data_rect was, so you could safely use it to create an appropriate data-to-screen transformation. But now that the rectangle can change size?specifically, when it grows smaller?the chart will extend beyond its margins, meaning that the graph will overwrite parts of its axes and generally look rather messy. Fortunately, GDI can solve that problem. Just call Graphics.SetClip(), passing in a clip rectangle, and any line segments you draw will be clipped appropriately.

Supporting Multiple Data Sets
Being able to draw a single graph is sufficient for many applications, but sometimes you need to be able to superimpose multiple graphs on the same set of axes. For example, it’s common to annotate a share price chart with some kind of moving average, or even a set of them. These additional graphs must be drawn to the same scale as the primary graph (you don’t want to draw another set of axes), and their data range should be independent of the chart’s final data rectangle. In other words, these secondary graphs are simply overlays on the primary graph; they shouldn’t have any effect on the primary chart itself).

You’ll also want to make some kind of visual distinction between these secondary data sets; therefore it’s time to move the data set implementation (previously just an array of PointF objects) into a separate class. The Chart.Study?class lets you customize the color and drawing style of each individual data study. It’s a nested class, because it has no significance outside the context of its parent. Figure 7 depicts a chart with a single study, and Figure 8 shows the same chart with a secondary study

Putting It All Together
You’ll notice that the chart’s Draw() method still requires a Control reference as a parameter that specifies the control into which the chart should render itself. I considered caching the control so you could call Transform() or Draw() with one less parameter; but that would tightly couple the chart to the dimensions and lifetime of that specific control. While that might be exactly what you want, most of the time, I couldn’t see any compelling reasons to presume it always was. For the same reason, I’ve left the Chart class as a class library, rather than turning it into a formal control, so that you can integrate it loosely into your own projects.

Adding the Chart.Study class prompted me to add more support for user color schemes. You can individually specify the chart’s background color, axis color and the color of any reference values displayed, in addition to the color and pen style of any studies you want to draw. You can also name the X and Y axes using the XLabel and YLabel properties respectively. Finally, the set of AtMinX(), AtMinY(), AtMaxX(), and AtMaxY() methods keep track of the position of m_data_rect with respect to m_limits_rect. That lets users scroll around a study freely, but takes appropriate action when they reach the bounds of that study. For example, you can disable the “left scroll” button when AtMinX() returns true. If users end up losing their view of the study completely, you can call Reset() to restore m_data_rect to its default position.

You can also subclass the Chart itself and override the two virtual methods, TranslateXValue() and TranslateYValue()to customize what kind of reference values get displayed. You might want to do that if you’ve had to perform some kind of type coercion to create your studies in the first place. For example, you might have converted a date or time object into a long or double, but you want to display a more user friendly string. If that’s the case, this is the place to do your conversion. TranslateXValue() and TranslateYValue() take a float to convert, along with an int specifying the number of decimal places to use for the conversion. Using the methods, you can decide how accurately you want your reference values to be aligned?for example, you could refuse to supply any reference values until you get to at least three decimal places. You also have the option of refusing to supply any reference value at all, typically because you’ve either been supplied an out-of-range index, an appropriate conversion is unavailable, or a reference value at the specified precision would be ambiguous.

Download the sample project and examine the code in to get a feel for what the Chart class can do, or take a look at the screenshot in Figure 9.

Figure 9. ChartTest in Action: The sample ChartTest project window displays charts along with reset, zoom, and scroll controls.

ChartTest loads a year’s worth of closing share price data for ticker symbol MSFT (Microsoft Corporation), and renders it in a PictureBox control with a nine-day simple moving average superimposed as a secondary study. It subclasses the Chart and overrides TranslateXValue() to render dates as strings along the X axis. To see the effect that TranslateXValue() has on the display, click the Dates/Values button to switch between the overridden and the default implementations. The main form contains two zoom buttons and four scroll buttons at the bottom right of the screen, which you can use to move around the chart . The buttons become disabled when you reach the edges of the data rectangle. Finally, you can snap any point to the center of the chart by clicking the left or right mouse buttons.

Future Directions
Playing around with the Chart class, I realized that there are still a few “nice to have” features missing. One would be to annotate each study with a descriptive string, so that, for charts containing multiple studies, you could tell which study was which. Another would be to optimize the rendering code. Finally, studies should perhaps be allowed to implement their own drawing logic so that they can render candlesticks, point-and-figure diagrams, or other custom formations. However, all these are application?specific. You should be able to fulfil the majority of your basic graphing needs with the class as it stands. This article should illustrate a few pitfalls and give a few pointers in case you want to extend the sample to develop your own more specialized charts.


About Our Editorial Process

At DevX, we’re dedicated to tech entrepreneurship. Our team closely follows industry shifts, new products, AI breakthroughs, technology trends, and funding announcements. Articles undergo thorough editing to ensure accuracy and clarity, reflecting DevX’s style and supporting entrepreneurs in the tech sphere.

See our full editorial policy.

About Our Journalist

©2024 Copyright DevX - All Rights Reserved. Registration or use of this site constitutes acceptance of our Terms of Service and Privacy Policy.