今日尝试写一款窗口上位机数据绘图助手Plotter的开发,实现接收解析数据包进行画图的功能:
文章提供完整代码解释、设计点解释、测试效果图、完整工程下载
目录
[字典存储每条曲线的 PointPairList:](#字典存储每条曲线的 PointPairList:)
控件摆放与使用控件大致介绍:
控件最终摆放效果如下图:
大致使用了如下控件:
TableLayoutPanel 自动排列组件,辅助实现控件与窗体的同步缩放
group 控件分组组件,与TableLayoutPanel 组合使用
ZedGraphControl 图表组件,用于绘图
serialPort 串口组件
checkbox 选框
下载必要的Nuget程序包:
这次编写的串口绘图助手程序需要以下几个Nuget程序包的支持:
图表绘制相关代码逻辑:
这一板块部分主要想实现的是:
绘制Y轴是数据,X轴是1ms级别时间戳的曲线
能绘制不止一条曲线,且每条曲线颜色可以自定
曲线是由点集构成,因此需要标注每个数据点
鼠标移动到图表曲线的点上显示点的X,Y精确坐标
字典存储每条曲线的 PointPairList:
先定义全局变量,来存储每条曲线的列表浮点数据值:
cs// 有一个全局或类级别的字典来存储每条曲线的PointPairList private Dictionary<string, PointPairList> curves = new Dictionary<string, PointPairList>();
绘制/更新曲线函数的编写:
这个自定义的函数只需传入以下几个参数:
ZedGraph.ZedGraphControl zgc:图表控件名称
string label : 曲线名称
double x, double y : x,y坐标
Color color : 曲线颜色
实现了有曲线就继续绘制,没曲线就根据传参数的曲线名称,创建一条曲线
cs// 初始化或更新曲线(如果曲线不存在,则创建它),并标记每个点 // 使用圆形(SymbolType.Circle)作为标记类型。 //这里使用 SymbolType.Triangle 三角形标记 // 如果想要使用其他类型的标记,可以将 SymbolType.Circle 替换为想要的类型。 private void InitializeOrUpdateCurve(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color) { GraphPane myPane = zgc.GraphPane; // 检查曲线是否已存在 if (!curves.ContainsKey(label)) { // 曲线不存在,创建新的PointPairList并添加到GraphPane中 PointPairList list = new PointPairList(); curves.Add(label, list); // 创建曲线并设置样式 LineItem myCurve = myPane.AddCurve(label, list, color, SymbolType.Triangle); // 假设想要三角形标记 // 设置标记的大小(您可以根据需要调整) myCurve.Symbol.Size = 4; // 由于我们想要标记颜色与线条颜色相同,所以不需要额外设置 // 但为了确保,我们可以显式设置填充颜色为线条颜色 myCurve.Symbol.Fill.Type = FillType.Solid; myCurve.Symbol.Fill.Color = color; } // 获取对应的PointPairList并添加新点 PointPairList listToUpdate = curves[label]; listToUpdate.Add(x, y); // 更新坐标轴并重新绘制图表 myPane.AxisChange(); zgc.Refresh(); }
调用写好的曲线绘制函数:
对于该函数的调用,我放置了一个按键控件,然后定义了几个全局变量,每次按下按键,都会将全局变量数值增加,然后作为图表曲线的数值绘制在对应名称的曲线上:
以下引用案例:
创建/继续绘制俩条图表曲线:MyCurve 红色 与MyCurve2 蓝色,按键每次按下将全局变量数值增加,绘制到曲线上
cs//绘制测试 private void Test_button_Click(object sender, EventArgs e) { //My_Plotter(1, 2); newX++; newY++; newX1++; newY1++; DrawNewPoint(zedGraphControl1, "MyCurve", newX, newY, Color.Red); DrawNewPoint(zedGraphControl1, "MyCurve2", newX1, newY1, Color.Blue); }
阶段绘制测试效果如下:
将时间戳作为X轴输入:
这里我想做到,从第一个数据开始计时,x轴是ms级别,我只需要传Y轴数值看其随着时间变化就行了:
先定义一些必要的变量如下:
cs// 声明一个DateTime变量来存储系统时间 private DateTime firstDataPointTime = DateTime.MinValue; // 初始化为一个不可能的值 (这个变量用于获取第一个变量出现时的时间) double timeStampInMilliseconds; //毫秒级计数 bool firstDataPoint_flag = false; //记录是否获取了第一个数据 DateTime newDataPointTime; // 新时间
然后将获取时间的逻辑加入测试按钮:
cs//绘制测试 private void Test_button_Click(object sender, EventArgs e) { newY1++; newY++; 当捕获到一个新的数据点时 DateTime newDataPointTime = DateTime.Now; // 捕获新数据点的时间 // 计算新数据点与第一个数据点之间的时间差(毫秒) if (firstDataPoint_flag == false) { // 记录第一个数据点的时间 firstDataPointTime = DateTime.Now; // 或者使用 DateTime.UtcNow 如果你想要 UTC 时间 DrawNewPoint(zedGraphControl1, "MyCurve",0, newY, Color.Red); DrawNewPoint(zedGraphControl1, "MyCurve2",0, newY1, Color.Blue); firstDataPoint_flag = true; } else if (firstDataPoint_flag == true) { 当捕获到一个新的数据点时 //newDataPointTime = DateTime.Now; // 捕获新数据点的时间 timeStampInMilliseconds = (newDataPointTime - firstDataPointTime).TotalMilliseconds; //timeStampInMilliseconds = currentTime.Ticks / TimeSpan.TicksPerMillisecond; DrawNewPoint(zedGraphControl1, "MyCurve", timeStampInMilliseconds, newY, Color.Red); DrawNewPoint(zedGraphControl1, "MyCurve2", timeStampInMilliseconds, newY1, Color.Blue); } }
时间戳绘制效果如下:
如图,我点击绘制测试按钮,越快线条越急抖,越慢线条越平缓
清空图表按钮实现:
这个没啥好注意的,主要是我实现的过程中忘记清空曲线字典列表了,导致清空一次后没法在此生成曲线:
cs
//清空图表
private void clear_button_Click(object sender, EventArgs e)
{
ClearChart(zedGraphControl1);
firstDataPoint_flag = false; //记录获取第一个数据 状态置零
}
// 清空图表中的所有曲线
private void ClearChart(ZedGraph.ZedGraphControl zgc)
{
GraphPane myPane = zgc.GraphPane;
// 遍历并删除所有曲线
while (myPane.CurveList.Count > 0)
{
myPane.CurveList.RemoveAt(0);
}
myPane.AxisChange();
// 刷新图表以显示更改
zgc.Refresh();
// 最后别忘记清理字典
ClearCurvesDictionary();
}
鼠标在数据点上获取精确值:
这个只需要开启它的一个属性就行:
**开了之后就是这样的效果:**711是毫秒,后面的小数点就是微秒数
串口接收事件生成与衔接图表逻辑:
对于检测端口卡顿、打开串口、发送数据等操作在之前的 串口助手窗体程序的制作中就已经实现了,本文主要讲串口接收部分怎么去衔接图表的逻辑。
单片机方面宏定义打印函数:
这里为了方便用户使用,进行了数据发送相关的宏定义:
cs// 宏定义的PRINT函数,第一个传入曲线名称,第二个传入曲线颜色,第三个传入你需要打印的数值 // 应用示例例: t1=95; PRINT(plot1,Red,"%d",t1); // 曲线名称:plot1 曲线颜色:红色 数据数值:t1变量的值 // 只需有这个宏定义就行了,后续的使用就是类似于: PRINT(plot1,Red,"%d",t1); 这样的格式 // 如果你颜色参数传输了没有定义的颜色,则默认曲线为 蓝色 // 可用颜色字段如下: // #define PRINT(title,color,fmt, args...) printf("{"#title"}""{"#color"}"fmt"\n", ##args)
单片机串口发送示例:
以下截图示例了如何用单片机串口发送你想要绘制的曲线、曲线颜色以及数据:
最终发送出去的数据会是这样的格式:
接收数据字符串字段函数:
这需要先定义一些全局变量进行接收数据字符串字段等:
csstring title;//串口获取的曲线名称 string color;//串口获取的颜色字段 double value;//串口接受的数据
csprivate void ParseData(string data) { string formattedLogMessage; //转接字符串用 // 假设数据格式为 "{title}{color}value",其中value是一个可以转换为double的数值 // 第一步:分割数据为三部分 string[] parts = data.Split(new[] { '}', '{' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 3) { // 数据格式错误 Console.WriteLine("Invalid data format: " + data); return; } // 第二步:提取title和color title = parts[0]; color = parts[1]; // 第三步:尝试将最后一部分转换为double if (double.TryParse(parts[2], out value)) { // 成功转换 //Console.WriteLine($"Title: {title}, Color: {color}, Value: {value}"); formattedLogMessage = string.Format("Title: {0}, Color: {1}, Value: {2}", title, color, value); myaddlog(0, formattedLogMessage); // 这里可以根据需要处理title, color, 和value } else { // 转换失败 //Console.WriteLine("Failed to parse value from data: " + data); myaddlog(1, "本次数据转换失败:丢包......"); } }
在串口接收事件中的调用解算:
首先这个事件时需要在serial1的事件(黄色图表)中打开启用的:
然后这一步不是最终实现,只是阶段性测试接收与解算是否成功:
cs//串口接收逻辑 private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { SerialPort sp = (SerialPort)sender; string indata = sp.ReadExisting(); // 读取所有可用的数据 // 假设每次接收的数据都是完整的一行,或者你可以通过特定的字符(如换行符'\n')来分割数据 // 这里我们使用'\n'作为数据结束的标志,根据你的实际情况可能需要调整 string[] lines = indata.Split('\n'); foreach (string line in lines) { if (!string.IsNullOrWhiteSpace(line)) { // 解析数据 ParseData(line); } } }
阶段性解算成果展示:
这里在日志中发现了阶段性接收解算的成功:
字典转换颜色字符串:
单片机发送的颜色是字符串,而不是我们上位机中的Color.Red属性,因此需要定义字典来转换一下:
cs// 创建一个颜色字典 private Dictionary<string, Color> colorDictionary = new Dictionary<string, Color> { { "Red", Color.Red }, { "Green", Color.Green }, { "Blue", Color.Blue }, { "Black",Color.Black}, { "Aqua",Color.Aqua}, { "Beige",Color.Beige}, { "AliceBlue",Color.AliceBlue}, { "AntiqueWhite",Color.AntiqueWhite}, // 可以根据需要添加更多颜色 };
可以如下方式来使用字典:
csColor colorValue=Color.Blue;//记录颜色变量 (默认蓝色) colorDictionary.TryGetValue(color, out colorValue);//这句会尝试将颜色字符与字典进行匹配,并传给colorValue
数据画图函数与非UI线程调用:
这是个比较抽像的问题,因为UI的绘制与串口接收不在同一个线程
如果尝试从非UI线程(即不是创建控件的线程,通常是主线程)访问UI控件就会抛出
System.InvalidOperationException
异常,提示"线程间操作无效: 从不是创建控件'zedGraphControl1'的线程访问它。"为了解决这个问题,需要确保所有对UI控件的访问都在UI线程上执行
有两个方法都允许你在控件的UI线程上执行委托(delegate)
Control.Invoke
或Control.BeginInvoke
方法但
BeginInvoke
是异步的,而Invoke
是同步的。以下是一个示例,展示了如何在非UI线程中安全地调用UI线程上的方法,以更新
zedGraphControl1
控件:这是要根据你需要调用的函数的传参等情况进行编写的,下面先放出需要跨线程调用的更新
zedGraphControl1
控件的函数:
csprivate void DrawNewPoint(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color)
cs//这是在非UI线程中调用的方法 private void UpdateGraphFromNonUiThread(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color) { // 检查zedGraphControl1是否已创建并且句柄已分配 if (zedGraphControl1.InvokeRequired) { // 使用BeginInvoke在UI线程上异步执行UpdateGraph方法 zedGraphControl1.BeginInvoke(new Action<ZedGraph.ZedGraphControl, string, double, double, Color>(DrawNewPoint), zgc,label,x,y,color); } else { // 如果已经在UI线程上,则直接调用UpdateGraph方法 DrawNewPoint(zgc,label,x,y,color); } }
然后就可以使用这个线程委托来调用数据绘图了:
cs//数据画图函数 private void plotData() { Color colorValue=Color.Blue;//记录颜色变量 (默认蓝色) colorDictionary.TryGetValue(color, out colorValue);//这句会尝试将颜色字符与字典进行匹配,并传给colorValue // 计算新数据点与第一个数据点之间的时间差(毫秒) if (firstDataPoint_flag == false) { // 记录第一个数据点的时间 firstDataPointTime = DateTime.Now; // 或者使用 DateTime.UtcNow 如果你想要 UTC 时间 //传参: zedGraphControl1 图表,"MyCurve" 曲线名, X数值,Y数值,颜色 UpdateGraphFromNonUiThread(zedGraphControl1,title, 0, value, colorValue); firstDataPoint_flag = true; } else if (firstDataPoint_flag == true) { 当捕获到一个新的数据点时 DateTime newDataPointTime = DateTime.Now; // 捕获新数据点的时间 timeStampInMilliseconds = (newDataPointTime - firstDataPointTime).TotalMilliseconds; UpdateGraphFromNonUiThread(zedGraphControl1,title, timeStampInMilliseconds, value, colorValue); } }
单条曲线绘图测试效果:
单片机写了一个程序,能发送一个让 t1 变量加到50,再从50减到0,以此循环
并发送的程序:
然后实测效果符合情况:
最终测试效果:
这次测试比之前的阶段测试添加了一条变量的曲线来绘制,它与之前的变量变换情况相反:
也是符合情况的:
最后提一嘴:别忘记这个单片机端的宏定义:
cs// 宏定义的PRINT函数,第一个传入曲线名称,第二个传入曲线颜色,第三个传入你需要打印的数值 // 应用示例例: t1=95; PRINT(plot1,Red,"%d",t1); // 曲线名称:plot1 曲线颜色:红色 数据数值:t1变量的值 // 只需有这个宏定义就行了,后续的使用就是类似于: PRINT(plot1,Red,"%d",t1); 这样的格式 // 如果你颜色参数传输了没有定义的颜色,则默认曲线为 蓝色 // 可用颜色字段如下: // #define PRINT(title,color,fmt, args...) printf("{"#title"}""{"#color"}"fmt"\n", ##args)
遇到的问题:
在非UI线程调用UI控件:
这是我尝试在串口中断的线程中更新数据到图表时产生的报错:
System.InvalidOperationException:"线程间操作无效: 从不是创建控件"zedGraphControl1"的线程访问它。"
这是个比较抽像的问题,因为UI的绘制与串口接收不在同一个线程
如果尝试从非UI线程(即不是创建控件的线程,通常是主线程)访问UI控件就会抛出
System.InvalidOperationException
异常,提示"线程间操作无效: 从不是创建控件'zedGraphControl1'的线程访问它。"为了解决这个问题,需要确保所有对UI控件的访问都在UI线程上执行
解决这个问题的具体实现在 数据画图函数与非UI线程调用: 这一节
整体测试工程下载:
这里先说声抱歉,我之前不会做上位机时,下载了类似功能的串口助手,能绘图,被迫付费使用了一阵子,这里资源设置为9.9付费了... (朋友免费~~)
https://download.csdn.net/download/qq_64257614/89631031?spm=1001.2014.3001.5503