分类: C/C++
2008-09-14 19:45:32
This article describes a graphing control that can be dropped into any Windows application to create scientific and technical graphs in a huge range of styles. The control plots any number of mathematical expressions in rectangular as well as polar mode. By "expression," I mean any combination of algebraic, trigonometric, exponential, logarithmic, hyperbolic or custom user-defined functions. There are various graphing controls at CodeProject (and outside), but almost all of them require us to provide a list of values (points). This control is unique in the way that it expects the user to provide an expression, e.g. 6*sin(5*x)*cos(8*x).
The most important issue for every custom control is how extensible and customizable it is, and how easy it is to use in other Windows applications. Some salient features of this control are:
IEvaluatable
interface
The public interface of the control is very handy. Various methods and properties are provided to provide a high degree of customization. Let us have a quick look at some selected methods and properties.
void AddExpression(IEvaluatable expression, Color color, bool visible) |
This function adds an expression to the graph. We will be looking at IEvaluatable later. color is the color of expression to be plotted while visible controls the visibility of the expression. |
void SetRangeX(double StartX, double EndX) |
This sets the range for the X-axis. For example, use SetRangeX(-5,15) to construct a graph having x-axis starting from -5 and ending at 15 |
void SetRangeY(double StartY, double EndY) |
This sets the range for the Y-axis. For example, use SetRangeY(15,25) to construct a graph having y-axis starting from 15 and ending at 25 |
void ZoomIn() |
Zooms-in the graph. Similarly, we have functions ZoomInX() (for zooming X-axis only), ZoomInY (for zooming Y-axis only) and the corresponding functions for zoom-out (ZoomOut , ZoomOutX and ZoomOutY ). Note that these functions automatically adjust their increasing/decreasing ratio, i.e., if we try to zoom-in/out while viewing a large scale graph, the zooming ratio is also large; and if we zoom a graph displayed for a short range, the zooming ratio is small. |
void MoveLeft(int division) |
Scrolls the graph to left the number of divisions specified. In a similar fashion, we have functions like MoveRight() , MoveUp() and MoveDown() . |
Bitmap GetGraphBitmap() |
Returns a bitmap object for the current graph. |
void CopyToClipboard() |
Copies the current graph to clipboard. |
double[] GetValues(double point) |
Evaluates all the expressions at a given point and returns the result as an array. |
ScaleX | double |
Base scale for the X-axis, e.g. if ScaleX is 10 then the graph will be created from -10 to 10. If we supply a -ve value, the axis is reversed, i.e. supplying ScaleX =-10 draws graph from 10 to -10. |
ForwardX | double |
Controls navigation within the X-axis. Note that the graph will be drawn from -ScaleX+ForwardX to ScaleX+ForwardX , so if we have specified ScaleX =20 and ForwardX =0 then the graph will draw X-axis from -20 to +20. Similarly, if we have set ScaleX =20 with ForwardX =-10 then the graph will have X-axis from -30 to +10. |
DivisionX | int |
No of grid divisions for the X-axis. |
PrintStepX | int |
Step (increment) for printing the X-axis labels. Its range is from 0 to DivisionX . If set to 0, the graph does not display any labels; if set to DivisionX , the graph displays labels with each grid division. |
GraphMode | Enum |
Switches between rectangular and polar modes. |
PolarSensitivity | double |
Adjusts the sensitivity for polar graphs. The higher the value, the more accurate the polar graph. |
DisplayText | bool |
Sets whether to display expression text inside the graph. |
Grids | bool |
Turns on/off the grids. |
PenWidth | int |
Adjusts the pen width for drawing graphs. |
Almost all these methods and properties are demonstrated in the application and the user interface of the demo project simply calls the respective methods of the control.
Before getting into implementation details of the control, we will have a look at its usage. The range for a graph can be controlled using properties (ScaleX
, ForwardX
, etc.) as well as functions (SetRange()
, ZoomIn()
, Move()
, etc). Constructing a graph using methods is easy while that using properties is recommended for advanced users that want to have greater control.
Let's construct a simple graph using methods. After we have created all the necessary references and placed the control (named "expPlotter
") on a Windows form, here's some code to experiment:
expPlotter.SetRangeX(-6, 14); //set the x-axis range from -6 to 14
expPlotter.SetRangeY(-5, 5); //set the y-axis scale from -5 to +5
expPlotter.DivisionsX = 5; //set no. of grid divisions
expPlotter.DivisionsY = 5;
expPlotter.PenWidth = 2; //set pen width for graph
//now add some expressions
expPlotter.AddExpression((IEvaluatable)new Expression("-exp(x/2-3)"),
Color.Green, true);
expPlotter.AddExpression((IEvaluatable)new Expression("2*sin(x/2)*cos(3*x)"),
Color.Blue, true);
expPlotter.AddExpression((IEvaluatable)new Expression("abs(x/2)"),
Color.Brown, true);
//we need to manually refresh our graph for the changes to take effect
expPlotter.Refresh();
The above code will produce the following output:
Let's reconstruct the same graph using properties, the more flexible approach.
expPlotter.ScaleX = 10; //set the base scale to -10 to +10
expPlotter.ForwardX = 4;
//since the base scale was -10 to +10 and our ForwardX value is 4
// so now our graph will have x-axis range: -6 to 14
expPlotter.ScaleY = 5; //set the y-axis scale from -5 to 5
expPlotter.ForwardY = 0; //Y-axis origin at the center
expPlotter.DivisionsX = 5; //set no. of grid divisions
expPlotter.DivisionsY = 5;
expPlotter.PenWidth = 2; //set pen width for graph
//add expressions
expPlotter.AddExpression((IEvaluatable)new Expression("-exp(x/2-3)"),
Color.Green, true);
expPlotter.AddExpression((IEvaluatable)new Expression("2*sin(x/2)*cos(3*x)"),
Color.Blue, true);
expPlotter.AddExpression((IEvaluatable)new Expression("abs(x/2)"),
Color.Brown, true);
expPlotter.Refresh(); //refresh the graph
The above code will produce the same output as the previous one. Similarly, other methods and properties can be used for further customization. Now we will have a look at the implementation of the control.
Plotting in rectangular mode: The control internally stores all expressions in a List
of IEvaluatable
interface. We iterate through all these expressions (List<Evaluatable>
) and start a loop from -ScaleX+ForwardX
to ScaleX+ForwardX
. For each value of the loop variable, we find the value of expression using IEvaluatable.Evaluate(loop variable)
and plot it. For continuity in our graph, we join the previously evaluated value to the newly evaluated value.
Plotting in polar mode: Nothing special is done for handling polar modes since we can transform polar coordinates to rectangular coordinates by using the famous formulae: x=r*cos(theta)
and y=r*sin(theta)
. We evaluate the expressions from -PI
to +PI
and plot the equivalent rectangular coordinates.
Here's the code for PlotGraph()
method:
void PlotGraph(Graphics g)
{
DisplayScale(g);
if (this.bDisplayText)
DisplayExpressionsText(g);
double X, Y;
double dPointX, dPointY;
double dLeastStepX, dLeastStepY;
double dMin, dMax, dStep;
int i;
//All the time, (X1,Y1) will be the previous plotted point,
//while (X2,Y2) will be the current point to plot.
//We will join both to have our graph continuous.
float X1 = 0, Y1 = 0, X2 = 0, Y2 = 0;
//This variable controls whether our graph should be continuous or not
bool bContinuity = false;
//divide scale with its length(pixels) to get increment per pixel
dLeastStepX = dScaleX / iLengthScale;
dLeastStepY = dScaleY / iLengthScale;
//prepare variables for loop
if (graphMode == GraphMode.Polar)
{
dMin = -Math.PI;
dMax = Math.PI;
dStep = dScaleX / iPolarSensitivity;
}
else //if (Rectangular Mode)
{
dStep = dLeastStepX;
dMin = -dScaleX + dForwardX;
dMax = dScaleX + dForwardX;
}
for (i = 0; i < this.expressions.Count; i++)
{
//check if expression needs to be drawn and is valid
if (expVisible[i] == true && expressions[i].IsValid == true)
{
bContinuity = false;
for (X = dMin; X != dMax; X += dStep)
{
if (dScaleX < 0 && X < dMax)
break;
if (dScaleX > 0 && X > dMax)
break;
try
{
//evaluate expression[i] at point: X
Y = expressions[i].Evaluate(X);
if (double.IsNaN(Y))
{
//break continuity in graph if expression returned a NaN
bContinuity = false;
continue;
}
//get points to plot
if (graphMode == GraphMode.Polar)
{
dPointX = Y * Math.Cos(X) / dLeastStepX;
dPointY = Y * Math.Sin(X) / dLeastStepY;
}
else // if (Rectangular mode;
{
dPointX = X / dLeastStepX;
dPointY = Y / dLeastStepY;
}
//check if the point to be plotted lies
//inside our visible area(i.e. inside our
//current axes ranges)
if ((iOriginY - dPointY + dForwardY /
dLeastStepY) < iOriginY - iLengthScale
|| (iOriginY - dPointY + dForwardY /
dLeastStepY) > iOriginY + iLengthScale
|| (iOriginX + dPointX - dForwardX /
dLeastStepX) < iOriginX - iLengthScale
|| (iOriginX + dPointX - dForwardX /
dLeastStepX) > iOriginX + iLengthScale)
{
//the point lies outside our current scale so
//break continuity
bContinuity = false;
continue;
}
//get coordinates for currently evaluated point
X2 = (float)(iOriginX + dPointX - dForwardX /
dLeastStepX);
Y2 = (float)(iOriginY - dPointY + dForwardY /
dLeastStepY);
//if graph should not be continuous
if (bContinuity == false)
{
X1 = X2;
Y1 = Y2;
// the graph should be continuous afterwards
// since the current evaluated value is valid
// and can be plotted within our axes range
bContinuity = true;
}
//join points (X1,Y1) and (X2,Y2)
g.DrawLine(new Pen(expColors[i], iPenWidth),
new PointF(X1, Y1), new PointF(X2, Y2));
//get current values into X1,Y1
X1 = X2;
Y1 = Y2;
}
catch
{
bContinuity = false;
continue;
}
}
}
}
}
The expression plotter control expects the expressions to implement IEvaluatable
. This way, we can increase the extensibility of our control since we can write any class with custom evaluation behavior. We just need to provide a definition for the following:
string ExpressionText
Get/Set the text of expression
bool IsValid
Should return true
if the expression can be evaluated without any exception
double Evaluate(double dvalueX)
This function should evaluate the expression at dvalueX
and return the result as double
. If the result cannot be calculated (e.g. log for a -ve number) then it should return double.NaN
.
The control contains a sample implementation of IEvaluatable
, the Expression
class. Let me briefly describe this implementation. The following pseudo-code can be used to evaluate a simple expression:
int runningTotal = 0;
Operator lastOperator = "+";
While ( Expression is not scanned )
{
if Expression.Encountered( operand )
runningTotal = runningTotal operand;
else if Expression.Encountered( operator )
lastOperator = operator;
}
Let's see how the above code works on a sample expression 2*5+6-9
:
However, the problem with this code is that it "always" evaluates left to right, "ignoring" operator precedences. Thus, 4+3*5
is evaluated as 35
instead of 19
because first 4+3=7
is evaluated and then multiplied by 5
to get 7*5=35
. I solved this problem by inserting parenthesis at appropriate positions in the expression and evaluating parenthesis first, i.e., I converted 4+3*5
to 4+(3*5)
. InsertPrecedenceBrackets()
is the function that does this stuff while the main function: EvaluateInternal()
, calls itself recursively whenever it encounters a parenthesis. The DoAngleOperation()
contains the definition of functions that the class supports.
Here's the code for EvaluateInternal()
:
public double EvaluateInternal(double dvalueX,
int startIndex, out int endIndex)
{
//dAnswer is the running total
double dAnswer = 0, dOperand = 0;
char chCurrentChar, chOperator = '+';
string strAngleOperator;
for (int i = startIndex + 1; i < textInternal.Length; i++)
{
startIndex = i;
chCurrentChar = textInternal[startIndex];
// if found a number, update dOperand
if (char.IsDigit(chCurrentChar))
{
while (char.IsDigit(textInternal[i]) || textInternal[i] == '.')
i++;
dOperand =
Convert.ToDouble(textInternal.Substring(startIndex,
i - startIndex));
i--;
}
//if found an operator
else if (IsOperator(chCurrentChar))
{
dAnswer = DoOperation(dAnswer, dOperand, chOperator);
chOperator = chCurrentChar;
}
//if found independent variable
else if (char.ToLower(chCurrentChar) == charX)
{
dOperand = dvalueX;
}
//if found a bracket, solve it first
else if (chCurrentChar == '(')
{
dOperand = EvaluateInternal(dvalueX, i, out endIndex);
i = endIndex;
}
//if found closing bracket, return result
else if (chCurrentChar == ')')
{
dAnswer = DoOperation(dAnswer, dOperand, chOperator);
endIndex = i;
return dAnswer;
}
else //could be any function e.g. "sin" or any constant e.g "pi"
{
while (char.IsLetter(textInternal[i]))
i++;
//if we got letters followed by "(", we've got a
//function else a constant
if (textInternal[i] == '(')
{
strAngleOperator = textInternal.Substring(startIndex,
i - startIndex).ToLower();
dOperand = EvaluateInternal(dvalueX, i, out endIndex);
i = endIndex;
dOperand = DoAngleOperation(dOperand, strAngleOperator);
}
else //constant
{
dOperand = this.constants[textInternal.Substring(startIndex,
i - startIndex).ToLower()];
i--;
}
}
//return if we got a NaN
if (double.IsNaN(dAnswer) || double.IsNaN(dOperand))
{
endIndex = i;
return double.NaN;
}
}
endIndex = textInternal.Length;
return 0;
}
Also, here are few lines from DoAngleOperation()
:
//this function contains definitions for supported functions,
//Ofcourse, we can add more here.
static double DoAngleOperation(double dOperand, string strOperator)
{
strOperator = strOperator.ToLower();
switch (strOperator)
{
case "abs":
return Math.Abs(dOperand);
case "sin":
return Math.Sin(dOperand);
case "arctan":
return Math.Atan(dOperand);
case "arcsinh":
return Math.Log(dOperand + Math.Sqrt(dOperand * dOperand + 1));
case "arccosh":
return Math.Log(dOperand + Math.Sqrt(dOperand * dOperand - 1));
case "MyCustomFunction":
return MyFunctionsClass.MyCustomFunction(dOperand));
:
:
}
}
The current implementation of the Expression
class contains definitions for abs
(absolute), sin
(trigonometric sine), cos
(trigonometric cosine), tan
(trigonometric tangent), sec
(trigonometric secant), cosec
(trigonometric cosecant), cot
(trigonometric cotangent), arcsin
(trigonometric sine inverse), arccos
(trigonometric cosine inverse), arctan
(trigonometric tangent inverse), exp
(exponent), ln
(natural logarithm), log
(logarithm in base 10), antilog
(antilog in base 10), sqrt
(square root), sinh
(hyperbolic sine), cosh
(hyperbolic cosine), tanh
(hyperbolic tangent), arcsinh
(hyperbolic sine inverse), arccosh
(hyperbolic cosine inverse) and arctanh
(hyperbolic tangent inverse). As mentioned previously, this list can be extended by adding custom user-defined functions.
Please note that the control does not redraw itself unless asked to do so. So, we manually need to call expPlotter.Refresh()
to reflect our changes on the graph. This is done because redrawing the control is a time-consuming task; it involves re-evaluation of all the expressions and re-plotting of all the points. I first thought of an AutoRefresh property for controlling this, but later dropped this idea because most of the time we will be performing more than one operation before refreshing the graph. Please tell me your thoughts on this.
The application fairly demonstrates the usage of the control. Since the control provides a very handy interface, we just need to call the respective functions of the control to make a cool application. A quick look at the event handlers of the program can give us a nice idea of how to use the control. Resize the graph window to observe how the control adjusts itself for every size. Enter expressions and notice how the application provides assistance (e.g. text coloring, inserting *
and parenthesis at appropriate places, etc) while entering expressions. I hope you will like this control. Happy graphing..