Use Transformations to Draw Your Own Great Graphs

Use Transformations to Draw Your Own Great Graphs

isual Studio comes with a huge number of pre-built components and controls, including controls for entering and displaying text, letting the user pick options and make choices, displaying values graphically, interacting with databases, displaying dialogs, and containing and arranging other controls. But it comes with surprisingly few controls for displaying graphical data. If you don’t want to shell out big bucks for a third-party graphing control, you’re pretty much stuck drawing your own pictures on a PictureBox.

Fortunately, drawing graphs isn’t all that hard. Mostly it’s a matter of drawing lines or boxes to connect some data points. The only really tricky details involve translating data values to and from the pixel coordinate system used to draw on the control.

Before getting to those issues, however, the following section describes the GreatGraph control’s features.

Fantastic Features
Figure 1 shows a form containing two GreatGraph controls that demonstrate many of the control’s features.

Figure 1. This form uses two GreatGraph controls to display four sets of data.

The control on the left in the figure shows three sets of data superimposed on one another.

  • The first set is displayed as a bar graph with yellow bars outlined in red. This data set also displays the points’ maximum, minimum, and average values using horizontal dotted blue lines. In the figure, the user’s mouse is over the data point (-3, 3.025675) so the control is displaying an appropriate tooltip. This data set also allows users to click and drag to change a point’s Y coordinate so the mouse cursor has changed to an up/down arrow.
  • The second set of data is rendered as a line graph with thick black dashed lines. This data set also displays tooltips but does not allow users to move data points.
  • The third set of data is displayed with the blue rectangles filled with white. This data set displays tooltips. It allows the user to change both X and Y coordinates so when the mouse moves over a data point the cursor changes to an up/down/left/right arrow cursor.

To represent these three different sets of data, the control uses three DataSeries objects. It also contains three additional DataSeries objects to represent the axes it displays. The X and Y axes are drawn in dark gray while the angled axis is drawn in green. These DataSeries objects are configured to display tick marks and labels. They don’t interact with the user so they don’t display tooltips or allow the user to move the data points.

The GreatGraph control on the right in Figure 1 draws a green line graph connecting a sequence of points that do not form a simple function y = F(x). It also contains a set of additional DataSeries objects to draw the silver circles showing different distances from the origin.

There are tons of other features you could add to a graphing control. You could add other kinds of labels (text placed arbitrarily on the graph), titles, keys, and legends. The GreatGraph control doesn’t provide these for two reasons. First, it’s next to impossible to predict all the possible combinations that you might want to display using all of these additional elements.

Second, it’s easy enough to add most of these above rather than within the control. For example, you can place a legend in a simple Label sitting above the GreatGraph control. While you can add these features if you want, I think you’ll find that the control is complicated enough as it is.

GreatGraph Control Architecture
The GreatGraph control’s basic design is relatively straightforward. The control holds a list of DataSeries objects. Each DataSeries object contains an array of PointF objects that represent data coordinates. While the GreatGraph control ties everything together, the DataSeries class does most of the control’s heavy lifting.

The DataSeries class provides a Draw method that causes the object to draw itself on a Graphics object’s surface. The class also provides properties that determine the data’s appearance when drawn. For example, properties determine whether the control draws its data as a bar graph, a line graph, or a series of points. Properties also control the pens and brushes used to draw the data, whether the result includes tick marks or labels, and whether users are allowed to click and drag to change the data points’ X or Y coordinates.

This architecture makes repainting simple?when it needs to repaint itself, the GreatGraph object simply loops through its DataSeries objects telling each to draw itself.

That’s the basic idea, anyway. Most of the particulars are not terribly complicated but there are a lot of them. The following sections provide more detail about how the control draws graphs, provides tooltips, and lets users click and drag data points around. You can download Visual Basic or C# versions of the example program to see all of the details and to watch the code in action.

Tricky Transformations
One of the most confusing parts of drawing a graph is making the data fit nicely on the graph’s surface. Only rarely does raw data in world coordinates map naturally to a control’s surface measured in device coordinates (pixels).

For example, suppose you want to graph sales over time, where the X axis measures time in months between 1 and 12, and the Y axis measures book sales in units between 0 and 20,000. To make the data fit nicely in a 300 x 300 pixel graph, you need to somehow scale the world coordinate units in the X (months) and Y (units) directions into the device coordinates (pixels).

To make fitting the data even more complex, the control’s device coordinates start with (0, 0) in the upper left corner, with X and Y coordinates increasing to the right and down. This is counterintuitive, because most people think of a graph’s coordinates as starting with (0, 0) in the lower left corner and increasing to the right and upward.

With a lot of patience and some tricky mathematics, you can probably figure out how to scale, translate, and flip the graph’s X and Y coordinates around to map your data in world coordinates onto the control’s surface in device coordinates. Fortunately you don’t need to go to all of that bother because the Graphics object provides methods that make these sorts of transformations easy. All drawing in .NET takes place on a Graphics object so mapping world coordinates to device coordinates is simply a matter of properly setting up the Graphics object’s transformations before you start drawing.

The Graphics object’s transformation methods are pretty powerful and let you arbitrarily translate, scale, and rotate any drawing operation. You can see my previous article Terrific Transformations for details about these general transformations, but the GreatGraph control doesn’t need that much flexibility. It only needs to map the world coordinates onto the control’s device coordinates in a reasonably straightforward way, so in this article I’ll stick to the simplest possible transformations.

You can build a complex series of transformations by using the Graphics objects methods but in this case there’s a shortcut. The Graphics object’s Transform property is a Matrix that determines how the Graphics object transforms everything that it draws. The Matrix class provides a constructor that builds an object representing a transformation from some rectangle to an arbitrary parallelogram. We can use that constructor to build a transformation from the world coordinate rectangle to the control’s device coordinate parallelogram, which is also a rectangle.

The following code shows how the GreatGraph control’s DrawToGraphics method draws the data it contains:

   ' Draw the graph on the Graphics object.   Public Sub DrawToGraphics(ByVal gr As Graphics, _    ByVal dxmin As Single, ByVal dxmax As Single, _    ByVal dymin As Single, ByVal dymax As Single)      ' Save the graphics state.      Dim original_state As GraphicsState = gr.Save()         ' Map the world coordinates onto the control's surface.      Dim world_rect As New RectangleF(Wxmin, Wymax, _          Wxmax - Wxmin, Wymin - Wymax)      Dim client_points() As PointF = { _          New PointF(dxmin, dymin), _          New PointF(dxmax, dymin), _          New PointF(dxmin, dymax) _      }      gr.Transform = New Matrix(world_rect, client_points)         ' Clip to the world coordinates.      gr.SetClip(world_rect)         ' Clear and draw the graph objects.      For Each obj As DataSeries In m_GraphObjects          obj.Draw(gr, Wxmin, Wxmax)      Next obj         ' Restore the original graphics state.      gr.Restore(original_state)   End Sub

The preceding code starts by saving the Graphics object’s state so it can restore it when it is done drawing.

Next the code builds a rectangle representing the world coordinates. It also creates an array of points to represent the upper left, upper right, and lower left corners of the parallelogram to which the Matrix should map the rectangle. The code then uses the rectangle and array of points to build the Matrix and saves it in the Graphics object’s Transform property.

The final step before drawing is to set the control’s clipping area to the world coordinate rectangle. That makes the Graphics object clip off any drawing commands that fall outside of this rectangle. The control’s drawing surface automatically clips to its edges but the control also uses this code to draw on printouts where the Graphics object is not clipped by the control’s boundaries. Setting the clipping region ensures that graphics won’t stray outside of the area where you want them on the printout.

At this point, the control is ready to draw. The code loops through the control’s DataSeries objects, calling each object’s Draw method. The next section of this article covers the DataSeries class in more detail.

After it has finished drawing, the code calls the Graphics object’s Restore method to restore the graphics state it saved earlier. That resets the Graphics object’s transformation and clipping region to whatever they were before the DrawToGraphics method started.

The following code shows how the control redraws itself when it receives a Paint event. The code clears its drawing surface and calls its own DrawToGraphics method, passing in the Graphics object on which to draw and its device coordinates. The DrawToGraphics method does all of the work:

   ' Draw the graph objects.   Private Sub GreatGraph_Paint(ByVal sender As Object, _    ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint   e.Graphics.Clear(Me.BackColor)      DrawToGraphics(e.Graphics, _       Me.ClientRectangle.Left, _       Me.ClientRectangle.Right, _       Me.ClientRectangle.Top, _       Me.ClientRectangle.Bottom)   End Sub

This code may seem a little indirect, calling the DrawToGraphics subroutine to do all of its work when you could just include the DrawToGraphics code right here in the Paint event handler. However, as the following section shows, putting the drawing code in a separate routine makes it easier to draw to print previews and image files.

Pretty Previews, Interesting Images
The GreatGraph control’s DrawToGraphics method can draw on any Graphics object. The control’s Paint event handler uses it to draw on the control’s surface but it can use the same method to draw on a print preview or bitmap.

Figure 2. Print Preview: The DataSeries object can easily draw its data in a print preview.

Figure 2 shows a print preview dialog displaying three images produced by the GreatGraph control. The two in the upper left and at the bottom were drawn by the same control shown on the left in Figure 1. The drawing on the upper right was drawn by the second control in Figure 1.

To display the print preview shown in Figure 2, the main program uses a PrintDocument component added to its form at design time. Listing 1 shows the PrintDocument object’s PrintPage event handler, which draws the graphs.

To draw the upper left graph, the code calls the GreatGraph1 control’s DrawToGraphics method, passing in the Graphics object representing the print preview page and the device coordinate bounds where it should draw. The DrawToGraphics method draws the graph’s data on the Graphics object just as it would draw on the control’s surface. The PrintPage code then draws a blue border around the graph to make it look nice and delineate the drawing.

Next, the PrintPage code repeats the same steps, passing a different rectangle to draw the large graph at the bottom of the preview.

The code calls the GreatGraph2 control’s DrawToGraphics method to make it draw the second graph in the preview’s upper right corner. It finishes by setting e.HasMorePages to False to tell the PrintDocument not to draw any more pages.

This simple example draws only three graphs on a single page, but you could easily modify the code to add text and other items on the print preview. For example, you could fill the page with report text and add a graph at an appropriate location.

To draw a graph onto a bitmap you follow steps similar to those used to draw onto a print preview. When you open the example program’s File menu and select the Save command, the program executes the following code, which creates a 600 x 600 pixel Bitmap object and a Graphics object to draw on it. It clears the Bitmap and calls the GreatGraph1 control’s DrawToGraphics method, passing it the Bitmap’s Graphics object and the Bitmap’s bounds so the control draws its data on the Bitmap.

The code finishes by saving the Bitmap in a JPEG file:

   ' Save an image of the graph in a Bitmap.   Private Sub mnuFileSave_Click(ByVal sender As System.Object, _      ByVal e As System.EventArgs) Handles mnuFileSave.Click         ' Make the Bitmap.      Const BM_WID As Integer = 600      Const BM_HGT As Integer = 600      Using bm As New Bitmap(BM_WID, BM_HGT)       
Figure 3. Drawing Graphs as Images: The GreatGraph control can draw its data into an image file, such as the JPG file shown here opened in Microsoft Paint.
' Make the control draw on the Bitmap. Using gr As Graphics = _ Graphics.FromImage(bm) gr.Clear(Color.White) GreatGraph1.DrawToGraphics( _ gr, 0, BM_WID - 1, 0, BM_HGT - 1) End Using ' Save the Bitmap. Dim file_name As String = _ Application.StartupPath If file_name.EndsWith("inDebug") Then _ file_name = file_name.Substring( _ 0, file_name.Length - 10) If Not file_name.EndsWith("") Then _ file_name &= "" file_name &= "Graph.jpg" bm.Save(file_name, _ Imaging.ImageFormat.Jpeg) MessageBox.Show("Saved file " & file_name) End Using End Sub

Figure 3 shows the resulting JPEG file opened in Microsoft Paint.

DataSeries Drawing
The DataSeries class represents a set of data points to be drawn. While the GreatGraph control ties everything together, the DataSeries class does most of the control’s heavy lifting. After the control’s code sets up the Graphics object’s transformation, the DataSeries object does all of the drawing.

Depending on a DataSeries object’s properties, the object can draw its data as a bar graph, as points connected by lines in various styles, or as a series of points represented by filled boxes. It can draw labels or tick marks next to the points, and it can draw lines showing the points’ minimum, maximum, and average values.

In fact, a DataSeries object can do all of these things at the same time. For example, you can make it draw a bar graph, a line graph, and a series of points on top of each other with tick marks, labels, and minimum, maximum, and average values. The result would be cluttered to the point of uselessness, but allowing you all of this flexibility actually makes the code simpler.

The following code shows the properties that determine how the DataSeries draws its data. The Points array holds the data values. The Parent property stores a reference to the GreatGraph control that contains the DataSeries. The Name property holds the DataSeries object’s name.

The control uses additional properties for specific drawing tasks. For example, the BarPen and BarBrush properties hold the pen and brush that the object should use when drawing its data as a bar graph. The LinePen property holds the pen that the object should use when drawing its data as a series of lines.

The AllowUserChangeX and AllowUserChangeY properties determine whether the user can click and drag to change data values’ X and Y coordinates at run time. ShowDataTips tells the object whether it should display a point’s value in a tooltip when the mouse moves over the point. Finally HitDx and HitDy are values you can set to determine how close to a point the mouse should be for the control to consider that the user has moused over the point.

The rest of the properties are relatively self-explanatory. They include values that determine how the DataSeries draws point boxes, tick marks, point labels, and average, minimum, and maximum value lines:

   ' The data points.   Public Points() As PointF = {}      ' The GreatGraph containing this object.   Public Parent As GreatGraph = Nothing      ' The data series name.   Public Name As String = ""      ' Bar drawing.   Public BarPen As Pen = Nothing   Public BarBrush As Brush = Nothing      ' Line drawing.   Public LinePen As Pen = Nothing      ' Point drawing.   Public PointWidth As Single = 0.5   Public PointPen As Pen = Nothing   Public PointBrush As Brush = Nothing      ' Tick mark drawing.   Public TickPen As Pen = Nothing   Public TickMarkWidth As Single = 1      ' Label drawing.   Public Labels() As String = Nothing   Public LabelFont As Font = Nothing   Public LabelsOnLeft As Boolean = False   Public LabelBrush As Brush = Nothing      ' Aggregate function drawing.   Public AveragePen As Pen = Nothing   Public MinimumPen As Pen = Nothing   Public MaximumPen As Pen = Nothing      ' Determines whether the user can change data.   Public AllowUserChangeX As Boolean = False   Public AllowUserChangeY As Boolean = False      ' Determines whether we display a data value tooltip.   Public ShowDataTips As Boolean = False      ' The X and Y distances from the mouse   ' to the cursor to indicate a data hit.   Public HitDx As Single = 0.25   Public HitDy As Single = 0.25

The following code shows the DataSeries class’s main Draw subroutine that coordinates all of the drawing. This routine takes as parameters the Graphics object on which it should draw and the smallest and largest X world coordinate values. It simply calls other subroutines to do the actual drawing:

   ' Draw the object.   Public Sub Draw(ByVal gr As Graphics, _      ByVal w_xmin As Single, ByVal w_xmax As Single)         DrawBar(gr)      DrawLine(gr)      DrawTickMarks(gr)      DrawPoint(gr)      DrawAggregates(gr, w_xmin, w_xmax)      DrawLabels(gr)   End Sub

Each of the DataSeries object’s drawing methods performs a single simple task, so their code is quite straightforward. The following code shows the DrawBar subroutine that draws the data as a bar graph:

   Private Sub DrawBar(ByVal gr As Graphics)      If BarPen Is Nothing Then Exit Sub         Dim wid As Single = Points(1).X - Points(0).X      Dim rects() As RectangleF      ReDim rects(Me.Points.Length - 1)      For i As Integer = 0 To Me.Points.Length - 1         If Points(i).Y > 0 Then            rects(i) = New RectangleF( _               Points(i).X - wid / 2, 0, _               wid, Points(i).Y)         Else            rects(i) = New RectangleF( _               Points(i).X - wid / 2, _               Points(i).Y, _               wid, -Points(i).Y)         End If      Next i         gr.FillRectangles(BarBrush, rects)      gr.DrawRectangles(BarPen, rects)   End Sub

DrawBar checks whether BarPen is Nothing. If so, the data should not be drawn in a bar graph, so the subroutine exits.

Next the routine calculates the width of the bars (it assumes they are all the same width). It loops through the Points array, making a RectangleF for each data point either above or below the X axis. Finally the code calls the Graphics object’s FillRectangles and DrawRectangles methods to fill and outline the bars. (Note that the program assumes that if BarPen is present then BarBrush is also present.)

The DrawLine subroutine is even simpler. It checks the object’s LinePen property and exits if LinePen is Nothing. It then just uses the Graphics object’s DrawLines method to draw lines between the data points:

   Private Sub DrawLine(ByVal gr As Graphics)      If LinePen Is Nothing Then Exit Sub      gr.DrawLines(LinePen, Points)   End Sub

The most complicated drawing code draws tick marks and point labels. The following code shows the DrawTickMarks subroutine. It first checks TickPen and exits if TickPen is Nothing:

   Private Sub DrawTickMarks(ByVal gr As Graphics)      If TickPen Is Nothing Then Exit Sub         For i As Integer = 0 To Points.Length - 1         ' Get a unit tick mark vector.         Dim tx, ty As Single         GetTickVector(i, tx, ty)            ' Draw the tick mark.         gr.DrawLine(TickPen, _            Points(i).X - tx, _            Points(i).Y - ty, _            Points(i).X + tx, _            Points(i).Y + ty)      Next i   End Sub

For each data point, the code calls GetTickVector to get a vector pointing in the right direction for the point’s tick mark. It then uses the Graphics object’s DrawLine method to draw the tick mark along this vector.

The GetTickVector method in Listing 2 finds a tick mark vector for a point. It starts by finding the direction vectors for the segments before and after the point. The code handles the first and last points specially, because they don’t have a previous or next segment respectively.

Drawing Labels
The code divides the previous and next vectors’ components by their lengths to get vectors of length 1. It then averages these unit vectors to get a new vector pointing halfway between the original vectors. To complete the operation, the code divides this vector’s components by its length and multiplies by half the desired tick mark width. The result is a vector that the program can use to draw a tick mark at this point.

The DrawLabels method shown in the following code also uses GetTickVector; however this code draws text, so it has some special needs. The Graphics object uses transformations to make drawing the data relatively straightforward. In particular, it scales the drawing to flip Y coordinates. That allows world coordinates to start with (0, 0) in the lower left corner and increase upwards as you normally expect on a graph. Unfortunately that means that any text you draw on the Graphics object appears flipped upside down. To fix this, the DrawLabels method adds another transformation to flip the text right-side up:

   Private Sub DrawLabels(ByVal gr As Graphics)      If Labels Is Nothing Then Exit Sub         ' Save the original transformation.      Dim old_transform As Matrix = gr.Transform         ' Flip the transformation vertically.      gr.ScaleTransform(1, -1, MatrixOrder.Prepend)         ' Draw the labels.      For i As Integer = 0 To Points.Length - 1         ' Get the tick mark direction vector.         Dim tx, ty As Single         GetTickVector(i, tx, ty)            ' Lengthen the tick mark vector to          ' add extra room for the text.         Dim lbl_size As SizeF = _            gr.MeasureString(Labels(i), LabelFont)         Dim extra_len As Single = 0.375 * _            Sqrt(lbl_size.Width * lbl_size.Width + _            lbl_size.Height * lbl_size.Height)         Dim tick_len As Single = Sqrt(tx * tx + ty * ty)         tx *= (1 + extra_len / tick_len)         ty *= (1 + extra_len / tick_len)            ' Draw the label.         Using sf As New StringFormat()            sf.Alignment = StringAlignment.Center            sf.LineAlignment = StringAlignment.Center            Dim x, y As Single            If LabelsOnLeft Then               x = Points(i).X + tx               y = -(Points(i).Y + ty)            Else               x = Points(i).X - tx               y = -(Points(i).Y - ty)            End If            gr.DrawString(Labels(i), _               LabelFont, LabelBrush, x, y, sf)         End Using      Next i         ' Restore the original transformation.      gr.Transform = old_transform   End Sub

The method starts by saving the Graphics object’s current transformation matrix in the variable old_transform. It then adds a scaling transformation at the beginning of the Graphics object’s sequence of transformations to flip all Y coordinates. Now, when the code draws text, this transformation flips the text upside down. Then the original transformations that map world coordinates to device coordinates flip the text right-side up again.

After adding the new transformation, the code loops through the object’s data points. For each point, it calls GetTickVector to find the tick mark vector for the point. It measures the point’s label text and uses the size to add some extra length to the vector to make room for the label.

The code then draws the label. To draw on the right side of the data, the code switches the sign of the lengthened tick mark vector. Because the newly added scaling transformation flips Y coordinates, the code also switches the sign of the label’s target Y position so it appears in its correct location. The new transformation flips the Y coordinate, inverting the string and placing it at the desired location in world coordinates. Then the Graphics object’s original transformations map the new point to its correct final destination in device coordinates, flipping the text back in the process.

Finally DrawLabels restores the Graphics object’s original transformation so other DataSeries objects can correctly map from world to device coordinates when drawing their data.

Terrific Tooltips
When you move the mouse over a data point, the control can display a tooltip that shows the point’s value. If the user is allowed to move the point, the control also displays an appropriate mouse cursor.

You can probably guess that these features begin in the control’s mouse event handlers. Unfortunately the mouse event handlers tell you where the mouse is in the control’s device coordinates. If you want to see whether a data point is beneath the mouse, you must somehow convert the device coordinates into world coordinates so you can compare the mouse’s position to the data points.

You could go back to the definitions of the transformation used to convert world coordinates to device coordinates and reverse the process with some fairly intense mathematics. Or you can take the easy way out and let the original transformation Matrix do the work for you.

The DeviceToWorld method shown below converts a point from device to world coordinates. It first creates a Matrix to transform points from world to device coordinates exactly as the control’s DrawToGraphics method does. It then calls the Matrix’s Invert method, which turns the Matrix into its inverse. The new Matrix represents the reverse of the operations represented by the original Matrix?meaning that instead of mapping world to device coordinates, the inverted matrix maps device to world coordinates.

The subroutine next builds an array containing the point that it should convert. It then calls the inverted Matrix’s TransformPoints method to apply the Matrix to the point. Finally the routine saves the resulting X and Y coordinates in its ByRef parameters for return:

   ' Convert a point from device to world coordinates.   Friend Sub DeviceToWorld(ByVal pt As PointF, _      ByRef x As Single, ByRef y As Single)         ' Make a transformation to map      ' world to device coordinates.      Dim world_rect As New RectangleF( _         Wxmin, Wymax, Wxmax - Wxmin, Wymin - Wymax)      Dim client_points() As PointF = { _         New PointF(Me.ClientRectangle.Left, _            Me.ClientRectangle.Top), _         New PointF(Me.ClientRectangle.Right, _            Me.ClientRectangle.Top), _         New PointF(Me.ClientRectangle.Left, _            Me.ClientRectangle.Bottom) _      }      Dim trans As Matrix = New Matrix(world_rect, client_points)         ' Invert the transformation.      trans.Invert()         ' Get the mouse's position in screen coordinates      ' and convert into control (device) coordinates.      Dim pts() As PointF = {pt}         ' Convert into world coordinates.      trans.TransformPoints(pts)         ' Set the results.      x = pts(0).X      y = pts(0).Y   End Sub

After you convert the mouse’s position into world coordinates, the rest is relatively straightforward. For example, here’s the GreatGraph control’s MouseMove event handler. It converts the mouse’s current position into world coordinates. It then calls subroutines SetTooltip and DisplayMoveCursor to display the tooltip and display a “move” cursor if appropriate:

   ' Display a tooltip if appropriate.   ' Display a point move cursor if appropriate.   Private Sub GreatGraph_MouseMove(ByVal sender As Object, _      ByVal e As System.Windows.Forms.MouseEventArgs) _      Handles Me.MouseMove         ' Convert the point into world coordinates.      Dim x, y As Single      DeviceToWorld(Me.PointToClient(Control.MousePosition), x, y)         ' Display a tooltip if appropriate.      SetTooltip(x, y)         ' Display a point move cursor if appropriate.      DisplayMoveCursor(x, y)   End Sub

The SetTooltip method shown in the following code loops through the DataSeries objects calling the ShowDataTip method on each one. The ShowDataTip method determines whether the point is over a data point and, if it is, displays the tooltip and returns True. The GreatGraph control’s SetTooltip method keeps calling the DataSeries objects’ ShowDataTip methods until one returns True:

   ' Display a tooltip if appropriate.   Private Sub SetTooltip(ByVal x As Single, ByVal y As Single)      ' See if a DataSeries object can display a tooltip.      For Each obj As DataSeries In m_GraphObjects         If obj.ShowDataTip(x, y) Then Exit Sub      Next obj         ' No DataSeries can display a tooltip.      ' Remove any previous tip.      tipData.SetToolTip(Me, "")   End Sub

Similarly, the control’s DisplayMoveCursor calls the DataSeries objects’ DisplayMoveCursor method. That method decides whether the mouse is over a moveable point and, if it is, displays a data move cursor and returns True.

Download the example code for additional details.

Moving Data
It’s only a small step from determining when the mouse is over a point to allowing the user to move that point. The GreatGraph control’s MouseDown event handler shown in the following code starts the process. It calls DeviceToWorld to convert the mouse’s current location into world coordinates. The code then calls the StartMovingPoint method.

StartMovingPoint loops through the DataSeries objects calling each one’s StartMovingPoint method, which returns True if the DataSeries has a moveable point at the mouse’s location. If the method returns True, the control’s code removes the control’s MouseMove event handler so the control doesn’t try to update tooltips while the user is moving the point, and prevents the tooltip from showing data point values while the selected data point is moving:

   ' If the mouse is over a moveable data point.   ' start moving it.   Private Sub GreatGraph_MouseDown(ByVal sender As Object, _      ByVal e As System.Windows.Forms.MouseEventArgs) _      Handles Me.MouseDown         ' Convert the point into world coordinates.      Dim x, y As Single      DeviceToWorld(Me.PointToClient(Control.MousePosition), x, y)         ' Start moving the data point if we can.      StartMovingPoint(x, y)   End Sub      ' Start moving a data point if appropriate.   Private Sub StartMovingPoint(ByVal x As Single, ByVal y As Single)      ' See if the cursor is over a moveable point.      For Each obj As DataSeries In m_GraphObjects         If obj.StartMovingPoint(x, y) Then            ' Uninstall our MouseMove event handler.            RemoveHandler Me.MouseMove, _               AddressOf GreatGraph_MouseMove            Exit Sub         End If      Next obj   End Sub

The rest of the work is done in the DataSeries class routines shown in the following code. The StartMovingPoint method returns False if the user is not allowed to move the DataSeries’ data. It calls the FindPointAt method to see if there is a point at this position (download the code to see the details). If there is no point at this location, StartMovingPoint returns False. However, if there is a point at this location, the method installs event handlers provided by the DataSeries class to watch for MouseMove and MouseUp events generated by the parent GreatGraph control.

The DataSeries class’s MouseMove event handler executes when the user has grabbed a data point in this DataSeries object and has moved the mouse. The event handler converts the mouse’s position from device to world coordinates. It updates the data point’s X and Y coordinates (whichever the user is allowed to change) and displays a tooltip showing the new value. It finishes by calling the GreatGraph control’s Refresh method to make the control redraw the graph to show the new data.

When the DataSeries object’s MouseUp event fires, the user has released the data point. The event handler removes the DataSeries object’s MouseMove and MouseUp event handlers and calls the parent control’s DataPointMoved method.

The GreatGraph control’s DataPointMoved method simply restores the control’s MouseDown event handler that was uninstalled by its StartMovingPoint subroutine:

   ' If (x, y) is over a moveable data point,   ' install MouseMove and MouseUp event handlers   ' to let the user move the point and return True.   Private m_MovingPointNum As Integer = -1      Friend Function StartMovingPoint( _      ByVal x As Single, ByVal y As Single) As Boolean         If (Not AllowUserChangeX) AndAlso (Not AllowUserChangeY) _         Then Return False         ' See if there's a data point here.      m_MovingPointNum = FindPointAt(x, y)      If m_MovingPointNum < 0 Then Return False         ' Install our event handlers.      AddHandler Parent.MouseMove, AddressOf Parent_MouseMove      AddHandler Parent.MouseUp, AddressOf Parent_MouseUp         Return True   End Function      ' The user is moving a data point.   Private Sub Parent_MouseMove(ByVal sender As Object, _      ByVal e As System.Windows.Forms.MouseEventArgs)      ' Get the mouse position in world coordinates.      Dim x, y As Single      Parent.DeviceToWorld(New PointF(e.X, e.Y), x, y)         ' Move the point.      If AllowUserChangeX Then Points(m_MovingPointNum).X = x      If AllowUserChangeY Then Points(m_MovingPointNum).Y = y         ' Set a new tooltip if appropriate.      ShowDataTip(x, y)         ' Redraw the graph.      Parent.Refresh()   End Sub      ' Stop moving the data point.   ' Uninstall our MouseMove and MouseUp event handlers   ' and let the parent know we moved the point.   Private Sub Parent_MouseUp(ByVal sender As Object, _      ByVal e As System.Windows.Forms.MouseEventArgs)         RemoveHandler Parent.MouseMove, AddressOf Parent_MouseMove      RemoveHandler Parent.MouseUp, AddressOf Parent_MouseUp         Parent.DataPointMoved()   End Sub

Simple, Yet Powerful
The GreatGraph control is pretty powerful. By using a simple DataSeries object to represent data, the control lets you build curves that are linear, simple functions, and even curves that loop and cross themselves.

Figure 4. New Features: Adding new features to the DataSeries class such as filled areas and radial lines is relatively easy.

The DataSeries class draws line, bar, and point graphs, optionally with tick marks, labels, and maximum, minimum, and average lines. Keeping each of these features separate lets you build and debug them separately, making the class simple yet flexible.

For example, it took only a few minutes for me to add two new features to the DataSeries class. The first fills the area between the data and the X-axis as shown in the upper left graph in Figure 4. The second new feature is the ability to draw radial lines connecting each point to the origin and is illustrated in the upper right graph in Figure 4. (The control on the bottom demonstrates translucent bar graphs, an effect that you could already achieve by using translucent pens.)

Behind all of these drawing features, transformations play a critical role. They allow the DataSeries class to easily draw graphs on a GreatGraph control, a print preview, or a Bitmap. Transformations allow the code to correctly position text for labels and to map the mouse's position from device to world coordinates so the control can display tooltips and allow the user to move data points.

To learn more about transformations, see my previous article Terrific Transformations and my book Visual Basic 2005 Programmer's Reference.


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