表达式绘图仪控件






4.83/5 (46投票s)
一个可以以矩形或极坐标模式绘制任意数量和任意组合数学表达式的控件


目录
引言
本文介绍了一个图形绘制控件,它可以被集成到任何Windows应用程序中,以创建各种风格的科学和技术图形。该控件可以绘制任意数量的数学表达式,支持直角坐标系和极坐标系。这里的“表达式”指的是代数、三角、指数、对数、双曲或用户自定义函数的任意组合。CodeProject(以及其他地方)提供了许多图形绘制控件,但几乎所有这些控件都要求我们提供一个点的列表。这个控件的独特之处在于,它要求用户提供一个表达式,例如 6*sin(5*x)*cos(8*x)。
特点
每个自定义控件最重要的考虑因素是其可扩展性、可定制性以及在其他Windows应用程序中使用的便捷性。该控件的一些显著特点是:
- 支持多种表达式,具有不同的颜色
- 支持直角坐标系和极坐标系
- 由于 `IEvaluatable` 接口,具有高度的可扩展性
- 支持图形的缩放和平移
- 能够将当前图形保存为图像
- 支持反转坐标轴
公共接口
该控件的公共接口非常方便。提供了各种方法和属性,以实现高度的定制化。让我们快速看一下一些选定的方法和属性。
方法
void AddExpression(IEvaluatable expression, Color color, bool visible) |
此函数向图形添加一个表达式。稍后我们将讨论 `IEvaluatable`。`color` 是要绘制的表达式的颜色,而 `visible` 控制表达式的可见性。 |
void SetRangeX(double StartX, double EndX) |
这设置了X轴的范围。例如,使用 SetRangeX(-5,15) 来创建一个X轴从-5开始到15结束的图形。 |
void SetRangeY(double StartY, double EndY) |
这设置了Y轴的范围。例如,使用 SetRangeY(15,25) 来创建一个Y轴从15开始到25结束的图形。 |
void ZoomIn() |
放大图形。类似地,我们有函数 `ZoomInX()`(仅放大X轴)、`ZoomInY`(仅放大Y轴)以及相应的缩小函数(`ZoomOut`、`ZoomOutX` 和 `ZoomOutY`)。请注意,这些函数会自动调整其放大/缩小比例,即,如果我们试图在查看大比例尺图形时进行放大/缩小,放大/缩小比例也很大;如果我们缩小显示短范围的图形,放大/缩小比例就很小。 |
void MoveLeft(int division) |
将图形向左滚动指定的“division”次数。同样,我们有 `MoveRight()`、`MoveUp()` 和 `MoveDown()` 等函数。 |
Bitmap GetGraphBitmap() |
返回当前图形的位图对象。 |
void CopyToClipboard() |
将当前图形复制到剪贴板。 |
double[] GetValues(double point) |
在给定的点评估所有表达式,并将结果作为数组返回。 |
属性
ScaleX | double |
X轴的基准比例,例如,如果 `ScaleX` 是10,则图形将从-10绘制到10。如果我们提供一个负值,则坐标轴反转,即提供 `ScaleX`=-10 将绘制从10到-10的图形。 |
ForwardX | double |
控制X轴内的导航。请注意,图形将从 `-ScaleX+ForwardX` 绘制到 `ScaleX+ForwardX`,因此,如果我们设置了 `ScaleX`=20 且 `ForwardX`=0,则图形的X轴将从-20绘制到+20。类似地,如果我们设置了 `ScaleX`=20 且 `ForwardX`=-10,则图形的X轴将从-30绘制到+10。 |
DivisionX | int |
X轴的网格划分数量。 |
PrintStepX | int |
打印X轴标签的步长(增量)。其范围从0到 `DivisionX`。如果设置为0,则图形不显示任何标签;如果设置为 `DivisionX`,则图形在每个网格划分处显示标签。 |
GraphMode | 枚举 |
在直角坐标系和极坐标系模式之间切换。 |
PolarSensitivity | double |
调整极坐标图的灵敏度。值越高,极坐标图越精确。 |
DisplayText | bool |
设置是否在图形内显示表达式文本。 |
Grids | bool |
打开/关闭网格线。 |
PenWidth | int |
调整绘制图形的笔触宽度。 |
几乎所有这些方法和属性都在应用程序中进行了演示,演示项目的用户界面只是调用了控件的相应方法。
Using the Code
在深入了解控件的实现细节之前,我们将先看看它的用法。图形的范围可以通过属性(`ScaleX`、`ForwardX` 等)和函数(`SetRange()`、`ZoomIn()`、`Move()` 等)来控制。使用方法构建图形很容易,而对于想要获得更大控制权的进阶用户,建议使用属性。
让我们使用方法构建一个简单的图形。在我们创建了所有必要的引用并将控件(命名为“`expPlotter`”)放置到Windows窗体后,这里有一些代码可以供您尝试:
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();
上面的代码将产生以下输出:

让我们使用更灵活的属性方法来重新构建相同的图形。
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
上面的代码将产生与先前相同的输出。类似地,可以使用其他方法和属性进行进一步的自定义。现在我们将看看控件的实现。
实现细节
ExpressionPlotterControl
直角坐标系绘制:控件内部将所有表达式存储在 `IEvaluatable` 接口的 `List` 中。我们遍历所有这些表达式(`List<Evaluatable>`),并从 `-ScaleX+ForwardX` 循环到 `ScaleX+ForwardX`。对于循环变量的每个值,我们使用 `IEvaluatable.Evaluate(loop variable)` 找到表达式的值并进行绘制。为了使我们的图形连续,我们将之前计算出的值连接到新计算出的值。
极坐标系绘制:极坐标系处理没有什么特别之处,因为我们可以使用著名的公式 `x=r*cos(theta)` 和 `y=r*sin(theta)` 将极坐标转换为直角坐标。我们从 `-PI` 评估表达式到 `+PI`,然后绘制等效的直角坐标。
这是 `PlotGraph()` 方法的代码:
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

表达式绘制控件要求表达式实现 `IEvaluatable`。这样,我们可以增加控件的可扩展性,因为我们可以编写任何具有自定义求值行为的类。我们只需要为以下内容提供定义:
string ExpressionText
获取/设置表达式的文本
bool IsValid
如果表达式可以无异常地进行求值,则应返回 `true`。
double Evaluate(double dvalueX)
此函数应在 `dvalueX` 处评估表达式,并返回 `double` 类型的结果。如果无法计算结果(例如,负数的对数),则应返回 `double.NaN`。
Expression: IEvaluatable
该控件包含 `IEvaluatable` 的一个示例实现,即 `Expression` 类。让我简要描述一下这个实现。以下伪代码可用于评估简单表达式:
int runningTotal = 0;
Operator lastOperator = "+";
While ( Expression is not scanned )
{
if Expression.Encountered( operand )
runningTotal = runningTotal <lastOperator> operand;
else if Expression.Encountered( operator )
lastOperator = operator;
}
让我们看看上面的代码如何处理示例表达式 `2*5+6-9`:

然而,这段代码的问题在于它“总是”从左到右求值,而“忽略”了运算符优先级。因此,`4+3*5` 被计算为 `35` 而不是 `19`,因为首先计算 `4+3=7`,然后乘以 `5` 得到 `7*5=35`。我通过在表达式的适当位置插入括号并先计算括号来解决这个问题,即我将 `4+3*5` 转换为 `4+(3*5)`。`InsertPrecedenceBrackets()` 是执行此操作的函数,而主函数 `EvaluateInternal()` 在遇到括号时会递归调用自身。`DoAngleOperation()` 包含该类支持的函数定义。
这是 `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;
}
此外,这里是 `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));
:
:
}
}
支持的函数
`Expression` 类的当前实现包含以下函数的定义:`abs`(绝对值)、`sin`(三角正弦)、`cos`(三角余弦)、`tan`(三角正切)、`sec`(三角正割)、`cosec`(三角余割)、`cot`(三角余切)、`arcsin`(三角反正弦)、`arccos`(三角反余弦)、`arctan`(三角反正切)、`exp`(指数)、`ln`(自然对数)、`log`(以10为底的对数)、`antilog`(以10为底的反对数)、`sqrt`(平方根)、`sinh`(双曲正弦)、`cosh`(双曲余弦)、`tanh`(双曲正切)、`arcsinh`(双曲反双曲正弦)、`arccosh`(双曲反双曲余弦)和 `arctanh`(双曲反双曲正切)。如前所述,通过添加用户自定义函数可以扩展此列表。
看点
请注意,控件不会自动重绘,除非被要求这样做。因此,我们需要手动调用 `expPlotter.Refresh()` 来反映图形上的更改。这样做是因为重绘控件是一项耗时的任务,它涉及到重新评估所有表达式并重新绘制所有点。我最初曾考虑为控制此功能添加一个 AutoRefresh 属性,但后来放弃了这个想法,因为大多数情况下,我们会在刷新图形之前执行多个操作。您对此有什么看法?
关于演示应用程序:图形绘制器
该应用程序充分演示了控件的用法。由于控件提供了非常方便的接口,我们只需要调用控件的相应函数即可构建一个很棒的应用程序。快速查看程序的事件处理程序可以让我们对如何使用该控件有一个很好的了解。调整图形窗口的大小,观察控件如何适应各种大小。输入表达式,注意应用程序在输入表达式时如何提供帮助(例如,文本着色,在适当位置插入 `*` 和括号等)。我希望您会喜欢这个控件。祝您绘图愉快。
历史
- 版本 1.0:初始版本
- 版本1.1:控件现在可以具有非矩形尺寸(截图如下)
