Logarithmic Linechart Component with GDI+/VB.NET
Introduction
I needed a logarithmic plot to work with a web page, but output .JPG to allow compression if needed, allow attachment of graph photos to email along with other photos, and allow printing of the jpg inside of various reports. Using a Log function from system.math is easy, but understanding how to make the graph paper look good and match the curve exactly was the the problem. I saw that the following article:
DataPlotter
- linear or logarithmic display of 2D data
Had that basic problem solved, but it didn't create a .JPG image on disk, and was a Winforms control, not a component that could be used in other environments. I also didn't find the documentation that I could have used, thus this article. Thanks to the author of the above article. Download the one .VB file source here: sourcecode;
Imports required by the LogPlotter Class:
Imports System.Drawing 'holds graphics, bitmap, brush, etc
Imports System.Drawing.drawing2d 'holds smoothingmode, pentype enumerations
Imports System.Drawing.Imaging 'holds imageformat enumeration
Imports System.math 'holds log function
Imports System.IO 'holds file class for deleting existing files
An Overview of the Interface looks like this:
(There are two constructors to handle 2 different range requirements that I have. You could add easily as many as you want here. My two are: 0-90 w/spacing of ten between markers, versus 0-260 w/spacing of twenty on the linear y-axis) The properties will be discussed later if they are not already somewhat apparent from the naming documentation. Note that the main Method, Render, has 3 inputs. 2 arrays of doubles, and the fullpath to the disk file you want created. The arrays must have the same length for the code to work. If these parameters are dynamically or human generated you may want to place some error code around this.
Public Class LogPlotter
Sub New()
_yRangeEnd = 90
_yGrid = 10
End Sub
Sub New(ByVal alternateRange As Boolean)
_yRangeEnd = 260
_yGrid = 20
End Sub
Private Declarations
Public Properties
Public Sub Render(ByVal xData() As Double, ByVal yData() As Double, ByVal filename As String)
Private Sub DrawVerticalLines(ByVal g As Graphics)
Private Function LargeFormat(ByVal value As String) As String
Private Sub DrawHorizontalLines(ByVal g As Graphics)
Private Sub DrawData(ByVal g As Graphics, ByVal xdata() As Double, ByVal ydata() As Double)
End Class
The LargeFormat Method above is so that 10000000 prints as 10M = 10 Million, 10000 prints as 10K = 10 thousand etc. Since Getting the grid paper that a chart prints to match exactly the data plotting has been my main challenge with GDI+ charting, you see that I have focused on those procedures for easier debugging.
Let's disuss the Render Method first:
First I'll explain the class, and then at the end I'll give an example regarding how I've used the class. The Render Method is the main method of the LogPlotter Class. Create a bitmap first in any physical size of jpg or gif or bmp that you might want to display or print at. I chose 300 height by 600 width because the x axis goes from .01 to 10 million and the 300 fits my form for displaying and for printing. Create a graphics object from the bitmap because that is what we can draw on. Set SmoothingMode Property to enumeration SmoothingMode to value AntiAlias to smooth the looks. Create a rectangle on the graphic so we can color the background with FillRectangle Method. Create x and y axis start end variables using border Properties that are set large enough to hold the descriptive labels that you want to put on the chart. Drawing the Vertical lines will be handled separately because with a log the increment as you go right on the x axis will not be linear as we are familiar with, but will actually increment faster and faster as we go right and we need vertical lines that can help us actually test that our data will hit the proper lines on the graph. The variable w0 is the ClientRectangle width adjusted by the right and left borders. This width must be adjusted to w1 which can be a little different than w0 due to rounding errors in dividing the width by an odd number of divisions into an odd distance between lines. Same with h1 and h0. If you don't understand this, run the code with odd demarcations like 27 and with distance between the lines of 17 versus 10 and trace the code to see how h1 becomes a little different thatn h0. If we did not adjust for this the plotted data could, for example, plot outside the grid. The variable d is the distance between lines, and n is the number of demarcations on the axis in both cases of x and y. We will study the horizontal lines which in this code are linear, not logarithmic. You can put something besides base 10 logging which is the most commonly used logging if you need that for a particular graph like if you wanted to graph photon travel upon explosions, you might need a more extreme compression of dimensions. Now we draw the rectangle around the graph lines. DrawData Method is the last major routine and will be discussed later. We save as .JPEG next and Disposes are more important here than in most coding since we are working with physical resources here that might block another operation done later.
Code Listing #1
Public Sub Render(ByVal xData() As Double, ByVal yData() As Double, _
ByVal filename As String)
Dim outputBitmap As New Bitmap(600, 300)
Dim g As Graphics = Graphics.FromImage(outputBitmap)
g.SmoothingMode = SmoothingMode.AntiAlias
Dim clientRectangle As New RectangleF(0, 0, 600, 300)
x0 = clientRectangle.Left + BorderLeft
y0 = clientRectangle.Top + BorderTop
w0 = clientRectangle.Width - BorderLeft - BorderRight
h0 = clientRectangle.Height - BorderTop - BorderBottom
x1 = clientRectangle.Right - BorderRight
y1 = clientRectangle.Bottom - BorderBottom
g.FillRectangle(New SolidBrush(ColorBg), clientRectangle)
Me.DrawVerticalLines(g)
w1 = d * n
Me.DrawHorizontalLines(g)
Dim penAxis As New Pen(ColorAxis, 1)
h1 = d * n
g.DrawRectangle(penAxis, x0, y0, w0, h0) ' draw axis
h0 = h1 'must correct internal width & height since equidistant
w0 = w1 'gridlines may not fit in axis rectangle w/o rounding errors
Me.DrawData(g, xData, yData)
If File.Exists(filename) Then
File.Delete(filename)
End If
outputBitmap.Save(filename, ImageFormat.Jpeg)
outputBitmap.Dispose()
g.Dispose()
End Sub
Let's discuss drawing the vertical lines of the chart:
The DrawVerticalLines Method shows how to draw the lines for logging which are not linear, but look like the image below. The horizontal line are drawn equidistant from each other. The log or vertical lines must show that 45 is not half way between 10 and 100 even though half of 90 is 45. And note that the lowest part of the data line is at the x axis value of 40,000. This is why the log paper must have the funny looking vertical lines. J

Image#1
The object g is the only input to the Method. We need a pen for the grid lines, and we need a brush for the labels for the axis lines. These are defined with the ColorGrid and ColorAxis properties of the Class LogPlotter that the current Method being discussed is packaged inside.
The variable n is the number of divisions or vertical line sections although there will be sub lines within a section. This explains why there is a loop on j inside the loop on i. Note that in a log axis versus a linear axis, n ignores the property XGrid and uses about 1 as the XGrid property since it is using the calculation shown below. Log(10000000) is about 16 and Log(.01) is about -4 so we get an n of about 20. So d is the distance in each section as the available graphing width w0 divided by how many sections. The position of a vertical line will be at x + d1. x is the same for each section, while d1 varies within the section. x is x0 the starting point on the x axis plus i times d. d1 is the log of j times d. Now draw the line from y0 to y1. Whew,,, why does that do it? Because j is varying from 1 to XLogBase -1. Our XLogBase is 10 so we want to show the 10 values between each section. The ten are not evenly divided, but log spaced. The Log(j) is the fraction of d where the line should be drawn within the section. This is the hardest part to understand if you feel you need to. Stare at it, play with it, run you variation and see what happens if you must. J Now print the label formatted like you want it.
Code Listing#2
Private Sub DrawVerticalLines(ByVal g As Graphics)
Dim penGrid As New Pen(ColorGrid, 1)
Dim brushAxis As New SolidBrush(ColorAxis)
n = Convert.ToInt32(Math.Log(XRangeEnd, XLogBase) - _
Math.Log(XRangeStart, XLogBase))
If n = 0 Then n = 1 ' we know we don't want to divide by zero
d = w0 / n
For i As Integer = 0 To n
x = x0 + i * d
If i < n Then 'do not draw the detailed gradations after the border
For j As Integer = 1 To XLogBase - 1
d1 = Convert.ToInt32(Math.Log(j, XLogBase) * d)
g.DrawLine(penGrid, x + d1, y0, x + d1, y1)
Next
End If
s = Me.LargeFormat(Convert.ToString(Math.Pow(XLogBase, _
Math.Log(XRangeStart, XLogBase) + i)))
Dim sf As SizeF = g.MeasureString(s, FontAxis)
g.DrawString(s, FontAxis, brushAxis, x - sf.Width / 2, y1 + sf.Height / 2)
Next
End Sub
Let's discuss drawing the horizontal lines of the chart:
The horizontal lines are linear
and so are much simple in this case, but remember the y axis could be logged
instead or as well. Here n is defined with both the range of values and the
YGrid property. This is just a really
simple version of the vertical lines.
Code Listing#3
Private Sub DrawHorizontalLines(ByVal g As Graphics)
Dim penGrid As New Pen(ColorGrid, 1)
Dim brushAxis As New SolidBrush(ColorAxis)
n = Convert.ToInt32((YRangeEnd - YRangeStart) / YGrid)
If n = 0 Then n = 1
d = h0 / n
For i As Integer = 0 To n
y = y1 - i * d
g.DrawLine(penGrid, x0, y, x1, y)
Dim s As String = Convert.ToString(YRangeStart + _
(YRangeEnd - YRangeStart) * i / n)
Dim sf As SizeF = g.MeasureString(s, FontAxis)
g.DrawString(s, FontAxis, brushAxis, x0 - sf.Width - sf.Height / 4, _
y - sf.Height / 2)
Next
End Sub
Let's discuss drawing the Data on the chart:
The hardest part here is
converting the data for the points into log versions so they fit on the new log
scale grid that we just drew. DrawData Method needs the graphic g and the 2
arrays of doubles. We need the pen to draw the line with. We need an array of
point objects called pts that expects the right number of dots to graph.
lastValidPt is just in case the log of some number is not graphable so we don't
crash. Remember when dimensioning arrays
to use 5 for an array of six elements since zero based. A point object has an X
property and Y property. The point must be at the axis starting point x0 or y0
plus some amount from the data values. Y is fairly simpler, so let's understand
that first. Y1 is the extreme bottom that any data can be graphed. Let's make
this example even simpler by looking at the case where we picked the YRangeStart
as 0 and the YRangeEnd as 1. Also for simplicity, assume ydata(i) is .5, then
the conversion is y1 which is 1 minus .5 divided by the height which is 1, so
.5. You need to see how when the values are different, this formula still
works. Once you get that, then the log version is the same almost except for
the log and that the axis origin in GDI+ by default is at the Northwest corner
of our grid. In other words x increases to the right, while y increases going
down. So x0 adds the formula on while y1 subtracts the formula.
Code Listing#4
Private Sub DrawData(ByVal g As Graphics, ByVal xdata() As Double, _
ByVal ydata() As Double)
Dim penDraw As New Pen(ColorDraw, PenWidth)
Dim pts(xdata.Length - 1) As Point
Dim lastValidPt As New Point(x0, y1)
For i As Integer = 0 To pts.Length - 1 'convert points to fit log grid
Try
pts(i).X = Convert.ToInt32(x0 + (Math.Log(xdata(i), XLogBase) - _
Math.Log(XRangeStart, XLogBase)) / (Math.Log(XRangeEnd, XLogBase) - _
Math.Log(XRangeStart, XLogBase)) * w0)
pts(i).Y = Convert.ToInt32(y1 - (ydata(i) - YRangeStart) / _
(YRangeEnd - YRangeStart) * h0)
lastValidPt = pts(i)
Catch ex As Exception
pts(i) = lastValidPt 'redraw last valid point on error
End Try
Next
For i As Integer = 0 To pts.Length - 1 'now draw the points
If i > 0 Then g.DrawLine(penDraw, pts(i - 1), pts(i))
Next
End Sub
Let's discuss using the LogPlotter Class from a web page:
After what we just went through,
using the class is calling the Render Method with 3 input parameters
Code Listing#5
Protected Sub createGraph_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles createGraph.Click
If Not samples.SelectedIndex = -1 Then
If Me.DataValid() Then
Dim logplot As New LogPlotter
Dim xData(4) As Double
xData(0) = pc21.Text
xData(1) = pc22.Text
xData(2) = pc23.Text
xData(3) = pc24.Text
xData(4) = pc25.Text
Dim yData(4) As Double
yData(0) = 5
yData(1) = 10
yData(2) = 15
yData(3) = 25
yData(4) = 50
Dim filename As String = "c:\graph.jpg"
logplot.Render(xData, yData, filename)
lblError.Text = "Graphic created"
Else
lblError.Text = "Data not plottable"
End If
Else
lblError.Text = "Select a sample to graph first"
End If
End Sub
Conclusion
One could add drawing modes like dash, dot versus solid line
or other colored lines overlapping each other easily. You could add titles or
legends type labeling to the graph drawing. If you want to stream your output
to a web page replace my bitmap.save line with:
Response.ContentType = "image/jpeg"
ouputBitmap.Save(Response.OutputStream, ImageFormat.Jpeg)
With this class it is easy to plot:
log x-axis versus log y-axis
log x-axis versus linear y-axis
linear
x-axis versus log y-axis
linear x-axis versus linear y-axis
It is easy to change ranges, have many ranges supported by the class, change spacing of demarcated lines, etc. Always check that your data hits the graph lines properly. If they do not, just trace the code relating to the out of synch value to see how it is getting plotted incorrectly.
In .NET Help/Index type System.Math. You will see the huge
list of member functions with Log and Pow in there in the middle
alphabetically. With GDI+ we have a lot of functions that can be used to plot
sophisticated charts, if one can only understand how to make the graph paper line
up with the data. I hope I've given an example here where there is a
sophisticated relationship and helped you understand it. If you can understand
this one you can probably do about any. Good luck out there.