In this diagram, the LOGICAL rectangle specified by (xMin,yMax)-(xMax,yMin) is mapped to the PHYSICAL coordinates specifed by r.left, r.right, r.top and r.bottom.
We can convert the logical coordinates x,y to physical coordinates X,Y as follows:
X = (r.right - r.left) * (x - xMin) / (xMax - xMin) + r.left
Y = (r.top - r.bottom) * (y - yMin) / (yMax - yMin) + r.bottom
similarily, to convert from physical coordinates X,Y to logical coordinates x,y:
x = (xMax - xMin) * (X - r.left) / (r.right - r.left) + xMin
y = (yMax - yMin) * (Y - r.bottom) / (r.top - r.bottom) + yMin
Note that these equations work even if r.top < r.bottom (as in most screen coordinate systems).
Now that we have a basic understanding of graphing theory, let's examine some code:
I've written the code as a BAS module that contains all the graphing functions. It could easily be rewritten as an ActiveX DLL or OCX.
The first step is to declare your API functions and type declarations. These are used instead of their VB equivalents because they can be used with ANY device context whereas VB methods are tied to a specific object type. So instead of having to have separate routines using picturebox.Line vs printer.Line, we can have a standard LineTo function and simply pass either the picturebox.hDC or the printer.hDC to this function.
In addition to the standard types like the RECT structure and POINTAPI structure, we add our own structures:
Code:
Private Type LineType
StartTime As Date
EndTime As Date
nPoints As Integer
AxisID As Integer
Pen As LOGPEN
Data() As Double
End Type
Private Type AxisType
max As Double
min As Double
name As String
units As String
End Type
These structures are used to store information about the lines and axes in our graph.
Each line has a start time and finish time and are linked to a specif axis. This gives us the complete LOGICAL coordinate system. The data is stored in an array where each value is a Y value (since we know when we start and when we end, plus how many points we have, we can calculate the corresponding X coordinate). Finally we have a LOGPEN structure to contain the information about the pen we will use to actually draw the graph.
Each axis has a maximum value and a minimum value. We also add supplementary information like name and units.
Next we add our module level variables:
Code:
Private sTitle As String 'graph title
Private nLines As Integer 'number of lines (sets of data) to draw
Private aLineData() As LineType '2D array of data
Private nAxes As Integer 'number of axes to use
Private aAxisData() As AxisType 'array of info on each axis
Private MaxTime As Date 'largest timestamp in all datasets
Private MinTime As Date 'smallest timestamp in all datasets
Private GraphRect As RECT 'the rectangle containing the actual graph lines
Private ZoomMin As Date 'current minimum x value
Private ZoomMax As Date 'current maximum x value
Since these are declared as PRIVATE, they are local to the module rather than being global. Limiting access in this way makes it easier to convert the module to an ActiveX DLL or OCX.
If you need to access these variables, you can use public functions to do so:
Code:
Public Function GetMinTime() As Date
GetMinTime = MinTime
End Function
Public Function GetMaxTime() As Date
GetMaxTime = MaxTime
End Function
We now add functions to put data into our axis and line arrays:
Code:
Public Sub AddLine(d() As Double, ByVal n As Integer, ByVal st As Date, ByVal et As Date, Optional ByVal AxisID As Integer = 0, _
Optional ByVal Color As Long = 0, Optional ByVal Style As Long = 0, Optional ByVal Width = 1)
'add a line to be drawn to the array of lines
Dim i As Long
ReDim Preserve aLineData(nLines)
With aLineData(nLines)
.AxisID = AxisID
.StartTime = st
.EndTime = et
.nPoints = n
.Pen.lopnColor = Color
.Pen.lopnStyle = Style
.Pen.lopnWidth.x = Width
.Pen.lopnWidth.y = Width
ReDim .Data(n - 1)
For i = 0 To n - 1
.Data(i) = d(i)
Next i
End With
nLines = nLines + 1
If et > MaxTime Then MaxTime = et
If st < MinTime Then MinTime = st
End Sub
Public Function AddAxis(ByVal max As Double, ByVal min As Double, ByVal name As String, ByVal units As String) As Integer
'add new axis to be drawn. Returns the ID of this axis
ReDim Preserve aAxisData(nAxes)
With aAxisData(nAxes)
.max = max
.min = min
.name = name
.units = units
End With
AddAxis = nAxes
nAxes = nAxes + 1
End Function
We also add a function to initialize all the variables and structures.
Code:
Public Sub NewGraph(ByVal Title As String)
'clears any existing data. Sets the title
nAxes = 0
nLines = 0
Erase aLineData
Erase aAxisData
MaxTime = 0 'set maxtime and mintime to impossible values
MinTime = Now() + 1
sTitle = Title
End Sub
Now that we have the data loaded, we can draw the graph:
Code:
Public Sub DrawGraph(ByVal dc As Long, r As RECT, ByVal st As Date, ByVal et As Date)
'draws the current graph to the device context dc
'within the rectangle specified by r
'the graph starts at st and ends at et
Dim te As Size
GetTextExtentPoint dc, sTitle, Len(sTitle), te 'get the length/height of the title string
TextOut dc, (r.Right - r.Left - te.cx) / 2, 0, sTitle, Len(sTitle) 'print the title centred at the top of the rect
r.Top = r.Top + te.cy 'reduce the height of the rect to put the title outsize the graphed area
'draw axes here. Reduce the size of the r rectangle to put the axes outside of the graphed area
'....not yet implemented
'save the current rectangle that we draw with
GraphRect.Right = r.Right
GraphRect.Left = r.Left
GraphRect.Top = r.Top
GraphRect.Bottom = r.Bottom
'save the current zoom times
ZoomMin = st
ZoomMax = et
'draw all the lines
DrawLines dc, r, st, et
End Sub
Basically, the function simply draws each section of the graph, then reduces the rectangle by the amount used for that section. At this time, I haven't written the axis labelling functions. The problem is that these functions are probably the trickiest part of graphing. It isn't easy to automatically determine a set of intervals that look good. Anyway, what is left is passed to the DrawLines function:
Code:
Public Function GetX(ByVal x As Long) As Double
'converts the x physical coordinate to logical coordinate (time)
GetX = (x - GraphRect.Left) * (ZoomMax - ZoomMin) / (GraphRect.Right - GraphRect.Left) + ZoomMin
End Function
Public Function GetY(ByVal y As Long, ByVal axis As Integer) As Double
'converts the y physical coordinate to logical coordinate for that axis
GetY = (y - GraphRect.Bottom) * (aAxisData(axis).max - aAxisData(axis).min) / (GraphRect.Top - GraphRect.Bottom) + aAxisData(axis).min
End Function
Public Sub DrawLines(ByVal dc As Long, r As RECT, ByVal st As Date, ByVal et As Date)
'draws all the lines
Dim i As Integer
Dim mx As Double
Dim mn As Double
Dim pt As Long
Dim sp As Integer 'start point
Dim ep As Integer 'end point
Dim dx As Double
Dim x As Long
Dim y As Long
Dim oldPt As POINTAPI
Dim xt As Double
Dim yt As Double
Dim cx As Double
Dim cy As Double
Dim newpen As Long
Dim oldpen As Long
Rectangle dc, r.Left, r.Top, r.Right, r.Bottom 'draw border
For i = 0 To nLines - 1 'for each line
With aLineData(i)
'get the maximum and minimum values for the y axis
mx = aAxisData(.AxisID).max
mn = aAxisData(.AxisID).min
'create the pen we want for the line
newpen = CreatePenIndirect(.Pen)
oldpen = SelectObject(dc, newpen)
dx = (.EndTime - .StartTime) / .nPoints 'dx is the amount of time between each point
sp = (st - .StartTime) / dx - 1 'sp is the starting point
If sp < 0 Then sp = 0 'sp can't be less than 0
ep = (et - .StartTime) / dx + 1 'ep is the ending point
If ep >= .nPoints Then ep = .nPoints - 1 'ep can't be more than the largest point
'determine the logical coordinates (xt,yt) of the first point (time,units)
xt = (sp * dx) + .StartTime
yt = .Data(sp)
'cx and cy are conversion factors to convert logical units to physical units (pixels)
cx = (r.Right - r.Left) / (et - st)
cy = (r.Top - r.Bottom) / (mx - mn)
'calculate the pixel coordinates (x,y)
x = (xt - st) * cx + r.Left
y = (yt - mn) * cy + r.Bottom
MoveToEx dc, x, y, oldPt 'moveto this first point
For pt = sp + 1 To ep
xt = xt + dx 'increment time by dx
yt = .Data(pt) 'get the next yt value
'convert to pixels
x = (xt - st) * cx + r.Left
y = (yt - mn) * cy + r.Bottom
LineTo dc, x, y 'draw the line
Next pt
'reset the pen
SelectObject dc, oldpen
DeleteObject newpen
End With
Next i
End Sub
The drawlines function will draw outside the graphing rectangle if you let it because it has no clipping function. On the screen, it isn't too bad because the picturebox automatically clips the line but if you add axis labels, it could be a problem. There are two general ways to clip a line, you could use a clipping rectangle or you could end the lines at the axis and use interpolation to find the y value. In general, interpolation might be better because not all devices contexts will support clipping rectangles.
That's pretty much it. You use the AddLine, AddAxis and NewGraph functions to setup the graph, and the DrawGraph function to draw it. You can easily zoom simply be adjusting what values you pass to the DrawGraph function.
Here is the complete project that draws to a picturebox and supports zooming.