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.