Monday, August 10, 2009

Line Chart in .net

You see line charts almost all the time. If you administer a web site, your log software probably uses a few charts to display your site's traffic. If you are into stock trading, charts are used extensively to display a stock's fluctuation over a period of time. There are numerous other examples of charts used everyday, but have you ever thought about how these charts are created?

Before answering that question for you, and to make up for my extremely boring intro paragraph, let me first show you the ASP.net chart this tutorial will help you deconstruct:



Don't let the static image trick you. Refresh this page or open the chart in a new window and keep refreshing that page. Notice that the chart looks different each time the page is loaded!

Since the above example was written entirely in code, in this tutorial I will first devote some time explaining how to approach designing a line chart before delving into how the code corresponds to our design.

Designing this Chart
There are two main features I focused on when designing this chart:

1. Being able to easily resize and adjust the chart size.
2. Allowing the chart to automatically adjust to wide ranges and quantities of data.

There are other features also, of course, but I will focus my attention on these two because I think they are most tricky to nail down properly in code. In the next page, I will elaborate on the above two features are designed.

Adjusting Chart Size
An important feature I mentioned earlier is the ability to easily adjust the chart size. When I say easily adjust the chart size, I am referring not to users being able to drag and resize the chart on the fly, but you as the developer being able to resize it in the code.
This is more tricky than it seems, for resizing your chart area should appropriately resize all of the columns, scale the plotted values appropriately, etc. The following image should provide you a brief overview of the various constraints most charts have placed on them:

As you can tell by the above image, your total chart area is only a smaller part of the total area available to it. The reason is because you want some room left over for the various labels, titles, etc. Because our .NET code generates an image, you also cannot have anything displayed outside of the image boundaries. Therefore, the extra space is all that you can use for displaying any information from this aspx file.
In the code, as you will see later on, in order to customize your chart's size, all you need to do is change the appropriate values for the four offsets as well as the total image width and height. The rest is taken care of by our code logic.

Accepting a Wide Range of Values
The chart you design should easily adapt to values outside of an acceptable range. For example, if you had to vertically plot 10,000 and then 10 afterwards, it wouldn't be feasible to have a chart that was at least 10,000 pixels high. Likewise, you wouldn't want your 10 value to be plotted vertically near your 10,000 value.
Your chart range should be both realistic as well as constrained by your chart height and width. To complicate things further, you may have many values that need to be plotted, or you may only have a few values that need to be plotted. Your chart should adapt to that variation in number of data points also, for the width of each column between two plotted values depends both on the number of data points being plotted as well as your chart's width.
To top things off, you have to deal with pixel values. For example, in some cases your column widths would need to be 5.3 pixels to ensure that all data points are plotted with the last data point hugging the right edge of the chart area. With a pixel value, your column widths would only be, using the above example, only 5 pixels wide. That means at the end, there will be some unused space associated with the .3 pixels being ignored. Multiply a loss of .3 pixels by each data point, and you are talking about real pixels leading to unnecessary gaps!

Looking at the Code

Now that you have a brief idea of what to expect with designing a chart, let's go look at the code that takes our design overview and turns into something usable. The following is the full code used for drawing the chart:
using System;
using System.Collections;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Drawing.Text;
using System.Web;
using System.Web.SessionState;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.HtmlControls;

public partial class Chart_Default : System.Web.UI.Page
{
Graphics gfx;
Bitmap bmp;

protected void Page_Load(object sender, EventArgs e)
{
bmp = new Bitmap(400, 300);
gfx = Graphics.FromImage(bmp);
gfx.Clear(Color.White);
gfx.SmoothingMode = SmoothingMode.AntiAlias;

// Define Points
//int[] p = new int[] { 10, 12, 48, 102, 290, 15, 100, 25, 300, 200, 150, 200, 60, 40, 250 };

int[] p = generateRandomValues();

int[] k = new int[p.Length];

Array.Copy(p, k, p.Length);
Array.Sort(k);

DrawChart(p, k[k.Length - 1], k[0]);
}
private void DrawChart(int[] points, int maxValue, int minValue)
{
//Offset (Margin) Values
int bottomOffset = 50;
int topOffset = 30;
int leftOffset = 60;
int rightOffset = 10;

// Taking care of some bookwork (declaring/initializing variables)
int maxDataPoints = points.Length;
int chartHeight = bmp.Height - bottomOffset;
int chartWidth = bmp.Width - rightOffset;

// Adjustable Values
double adjustedMax = maxValue * .10 + maxValue;
double adjustedMin = minValue - .50 * minValue;
double adjustVerticalRatio = (chartHeight-topOffset) / adjustedMax;
double adjustHorizontalRatio = ((chartWidth - leftOffset) / (maxDataPoints - 1));

Pen chartPen = new Pen(Color.Orange, 3);
Pen gridLine = new Pen(Color.LightGray, 1);

int minYpos = chartHeight - topOffset;
int maxYpos = 10;

// Drawing the Lines
for (int i = 0; i < maxDataPoints - 1; i++)
{
int xPos = Convert.ToInt32(i * adjustHorizontalRatio) + leftOffset;
int xPos2 = Convert.ToInt32((i + 1) * adjustHorizontalRatio) + leftOffset;

int yPos = Convert.ToInt32(chartHeight - adjustVerticalRatio * points[i]);
int yPos2 = Convert.ToInt32(chartHeight - adjustVerticalRatio * points[i + 1]);

if (points[i] == minValue)
{
minYpos = yPos;
}

if (points[i] == maxValue)
{
maxYpos = yPos;
}

gfx.DrawLine(gridLine, new Point(xPos2, topOffset), new Point(xPos2, chartHeight));
gfx.DrawLine(chartPen, new Point(xPos, yPos), new Point(xPos2, yPos2));

gfx.DrawString(i.ToString(), new Font("Arial", 8), new SolidBrush(Color.Gray), new Point(xPos - 4, chartHeight + 10));
}

//Draw Border Lines
Pen borderLine = new Pen(Color.DarkGray, 2);

//Left Border
gfx.DrawLine(borderLine, new Point(leftOffset, chartHeight), new Point(leftOffset, topOffset));

//Bottom Border
gfx.DrawLine(borderLine, new Point(leftOffset, chartHeight), new Point(chartWidth, chartHeight));

//Right Border
gfx.DrawLine(borderLine, new Point(chartWidth, chartHeight), new Point(chartWidth, topOffset));

//Top Border
gfx.DrawLine(borderLine, new Point(leftOffset, topOffset), new Point(chartWidth, topOffset));

//Drawing Vertical Min/Max Values
gfx.DrawString(maxValue.ToString(), new Font("Arial", 8), new SolidBrush(Color.Gray), new Point(leftOffset - 25, maxYpos));
gfx.DrawString(minValue.ToString(), new Font("Arial", 8), new SolidBrush(Color.Gray), new Point(leftOffset - 25, minYpos));
gfx.DrawLine(gridLine, new Point(leftOffset - 25, minYpos), new Point(chartWidth, minYpos));
gfx.DrawLine(gridLine, new Point(leftOffset - 25, maxYpos), new Point(chartWidth, maxYpos));

// Title
gfx.DrawString("[ Plotting Random Numbers ]", new Font("Arial", 10, FontStyle.Bold), new SolidBrush(Color.FromArgb(0, 102, 204)), new Point(leftOffset + 60, topOffset-30));

//Finalizing and Cleaning Up
Response.ContentType = "image/jpeg";
bmp.Save(Response.OutputStream, ImageFormat.Jpeg);
bmp.Dispose();
gfx.Dispose();
Response.End();
}

private int[] generateRandomValues()
{
Random rand = new Random();
int numValues = rand.Next(10, 20);

int[] newArray = new int[numValues];

for (int i = 0; i < numValues; i++)
{
newArray[i] = rand.Next(100, 1000);
}
return newArray;
}
}

I will not be going through every line of code like I normally do. There are two reasons for that:
1.The code itself is fairly simple. I sacrificed small-picture things for the big-picture result. In non-MBA speak, that means I only coded the important features. Features that would be nice to have but wouldn't drastically affect the chart were omitted.
2.For covering each line, the number of pages in this tutorial would be huge. Instead, future tutorials will focus in detail on bits and pieces of this code.
With that said, I will provide an overview of what the code does and then cover some of the more interesting aspects in greater detail.
Code Overview
The amount of code written may seem like a lot, but hopefully after this section you will see that it is just many lines doing simple things. Many things will make sense when you understand that all of your chart data is stored as an array of integers.
For example, this is how your data might look like in the array:
int[] points = {10, 20, 15, 30, 5, 23, 19};
So you have seven data points with the range of numbers going from a minimum of 5 and a maximum of 23. Your goal is to plot the seven numbers while normalizing the chart for minimum and maximum values of 5 and 23.
Our program takes this integer array, maximum value, and minimum value and gets it to the chart form in the DrawChart method. Inside the DrawChart method, you specify the details of the chart itself. For example, properties such as how wide/tall the chart will be, where the chart offsets (gaps) are, etc, are specified.
Once you have the constraints of our chart specified, it is time to draw our chart. Drawing a line is essentially having a starting point and an ending point, and having infinitely small dots connecting both the starting and ending points. More realistically, a chart works by drawing a line from the first value to the second value, from the second value to the third value, etc. until your last value is reached.
Beyond this, you have a lot of code that generates the various lines, text labels, etc. We aren't using any GUI-based tools to draw the interface. Everything is done in code, and that can be a bit confusing if you have never designed an interface using only code.

Setting the Image Properties

The output of our code will be an image, and there are four lines that describe the image setup:
bmp = new Bitmap(400, 300);
gfx = Graphics.FromImage(bmp);
gfx.Clear(Color.White);
gfx.SmoothingMode = SmoothingMode.AntiAlias;
An image in computer-terms can be considered a bitmap where each pixel contains some color value. In the first line, I set the bitmap's dimensions to be 400 pixels wide and 300 pixels tall. I apply the bitmap by using the Graphics method to create an image out of our initial bmp definition.
bmp = new Bitmap(400, 300);
gfx = Graphics.FromImage(bmp);
gfx.Clear(Color.White);
gfx.SmoothingMode = SmoothingMode.AntiAlias;
Now that we assigned our bitmap to our Graphics gfx variable, we won't directly be dealing with our Bitmap object for a while. With the gfx.Clear line, I am essentially clearing the background and setting a default White color.
When you draw shapes and lines, by default they are quite jagged. In order to have them look smoother - antialiased - you will need to set your graphic object's SmoothingMode property to SmoothingMode.AntiAlias.
The following is an image of our chart without the SmoothingMode set to AntiAlias:

Notice the the corners and edges of the lines have a very jagged feel to them.
Getting the Maximum and Minimum Values
The data points plotted are originally stored in an array. In the code, you will see that a generateRandomValues() method returns an array containing what the method name promises, random values.
There are many ways to determine the maximum values. One, less efficient way, is the method I explained for Flash in the following tutorial. In this code, I take a more efficient approach:
int[] p = generateRandomValues();

int[] k = new int[p.Length];

Array.Copy(p, k, p.Length);
Array.Sort(k);

DrawChart(p, k[k.Length - 1], k[0]);
In this code, what I am doing is storing the array of numbers in the p variable. I then make a copy of the p variable and store it into array k. I will explain why I do that in a few lines.
The Array.sort() method takes an array as its argument and sorts numbers inside it from largest to smallest. Therefore, the array's numbers would be ordered in smallest to largest like:
k = [smallest,...,largest];
So by simply taking the first value, I get the smallest value in the array. By taking the last value, I get the largest value in the array. Because the Array.sort() method modifies the contents of the array itself, I cannot plot array k. If I were to plot array k, my chart would plot the sorted array instead of the original, random array.
To display my original array, I create a copy of my array prior to actually sorting the array's numbers. That way, I independently have both the max and min values while still having a copy of my original array with which to plot the various numbers.

Normalizing the Chart

One of my design goals was to make the chart be capable of plotting a wide range of values. Like I mentioned earlier, you are constrained by your chart's height and width, so you will need to ensure that the highest and lowest values of your chart display in a reasonably accurate scale.
The following is the code that normalizes the chart's height and width:
// Taking care of some bookwork (declaring/initializing variables)
int maxDataPoints = points.Length;
int chartHeight = bmp.Height - bottomOffset;
int chartWidth = bmp.Width - rightOffset;

// Adjustable Values
double adjustedMax = maxValue * .10 + maxValue;
double adjustedMin = minValue - .50 * minValue;
double adjustVerticalRatio = (chartHeight-topOffset) / adjustedMax;
double adjustHorizontalRatio = ((chartWidth - leftOffset) / (maxDataPoints - 1));
One good way of looking at this is via an example. Let's say your maximum data point is 1000, and your chart's height is 100 pixels. That means, for every pixel, you have to cover 10 points of data so that you can display your 1000-value data point. That also means that a value of 500, will be 50 pixels high. Similarly, a data point that is 100, will only be 10 pixels tall.
What you are trying to do is come up with a good ratio between your chart's height and the number of data values each pixel will cover. The ratio is, as shown above, determined only by your maximum data point, and the ratio is what the adjustVerticalRatio variable stores.
Likewise for the horizontal case, the adjustHorizontalRatio variable stores the distance each data point must be separated by in order to fill up the horizontal space. Ideally, that would be the chart's width divided by the number of data points.
Notice in a lot of the above cases, I take into account any vertical or horizontal offsets that you may have introduced.
Plotting the Lines
To plot the lines, you first determine the starting and ending X and Y positions. Because in order to draw any lines, you need a starting point as well as an ending point. In our case, since all the lines are interconnected, your ending point for one line is the starting point for the next line.
The code for determining the two x and y positions can be found below:
int xPos = Convert.ToInt32(i * adjustHorizontalRatio) + leftOffset;
int xPos2 = Convert.ToInt32((i + 1) * adjustHorizontalRatio) + leftOffset;

int yPos = Convert.ToInt32(chartHeight - adjustVerticalRatio * points[i]);
int yPos2 = Convert.ToInt32(chartHeight - adjustVerticalRatio * points[i + 1]);
Some key things to note are how the earlier adjustHorizontalRatio and adjustVerticalRatio values are being used. Notice also that I am converting all of the earlier data into integers, for as you will see in the next line, the x and y positions are specified as a Point object that only accepts integer values:
gfx.DrawLine(chartPen, new Point(xPos, yPos), new Point(xPos2, yPos2));
Drawing Border Lines
If you look at the chart, you will see that it contains border lines that clearly separate the chart from the rest of the drawing area. Notice that the code for drawing the border lines is not contained inside the for loop. It only needs to be drawn once, it is outside of the loop.
//Draw Border Lines
Pen borderLine = new Pen(Color.DarkGray, 2);

//Left Border
gfx.DrawLine(borderLine, new Point(leftOffset, chartHeight), new Point(leftOffset, topOffset));

//Bottom Border
gfx.DrawLine(borderLine, new Point(leftOffset, chartHeight), new Point(chartWidth, chartHeight));

//Right Border
gfx.DrawLine(borderLine, new Point(chartWidth, chartHeight), new Point(chartWidth, topOffset));

//Top Border
gfx.DrawLine(borderLine, new Point(leftOffset, topOffset), new Point(chartWidth, topOffset));
The DrawLine method takes three arguments in this case. The first argument is a Pen object that defines both the color and thickness of the line. The second and third arguments are our familiar Point values that each take an x and y integer value.
Notice that the arguments passed into Point correspond to the edges as defined earlier in the following image:

Outputting as an Image
I started off the code explanation by describing how you would setup your image's bitmap and graphics variables. We go full circle now and have reached the point where we close the image and set its output properties.
The code for outputting the final result as an image is as follows:
//Finalizing and Cleaning Up
Response.ContentType = "image/jpeg";
bmp.Save(Response.OutputStream, ImageFormat.Jpeg);
bmp.Dispose();
gfx.Dispose();
Response.End();
Notice I set the content type as "image/jpeg" to ensure the output is set to the JPEG format. An image is created on the fly each time your aspx page is accessed, so if you were to place your aspx page between img tags in HTML, you will see the image produced by your code.
Conclusion

I hope this article gave you a brief understanding of not only how to approach designing a chart, but also how to draw in ASP.net.
I hope the information helped. If you have any questions or comments, please don't hesitate to post them on the kirupa.com Forums. Just post your question and I, or our friendly forum helpers, will help answer it.

No comments: