Chinaunix首页 | 论坛 | 博客
  • 博客访问: 4627321
  • 博文数量: 671
  • 博客积分: 10010
  • 博客等级: 上将
  • 技术积分: 7310
  • 用 户 组: 普通用户
  • 注册时间: 2006-07-14 09:56
文章分类

全部博文(671)

文章存档

2011年(1)

2010年(2)

2009年(24)

2008年(271)

2007年(319)

2006年(54)

我的朋友

分类: C/C++

2008-09-14 19:45:32

A graph in rectangular mode A graph in polar mode

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:

  • Multiple expressions with different colors
  • Rectangular as well as polar mode
  • High extensibility due to the IEvaluatable interface
  • Capability of zooming and scrolling the graph
  • Ability to save the current graph as an image
  • Capability to reverse axes

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:

Example1

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:

Collapse
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;
                }
            }
        }
    }
}

IEvaluatable

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:

Expression evaluation

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():

Collapse
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..

  • Version 1.0: Initial version
  • Version 1.1: The control can now have non-rectangular size (snapshot below)
A non-rectangular sized graph

License

This article, along with any associated source code and files, is licensed under

阅读(967) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~