今日尝试写一款窗口上位机数据绘图助手Plotter的开发,实现接收解析数据包进行画图的功能:
文章提供完整代码解释、设计点解释、测试效果图、完整工程下载
目录
控件摆放与使用控件大致介绍:
下载必要的Nuget程序包:
图表绘制相关代码逻辑:
字典存储每条曲线的 PointPairList:
绘制/更新曲线函数的编写:
调用写好的曲线绘制函数:
阶段绘制测试效果如下:
将时间戳作为X轴输入:
时间戳绘制效果如下:
清空图表按钮实现:
鼠标在数据点上获取精确值:
串口接收事件生成与衔接图表逻辑:
单片机方面宏定义打印函数:
单片机串口发送示例:
接收数据字符串字段函数:
在串口接收事件中的调用解算:
阶段性解算成果展示:
字典转换颜色字符串:
数据画图函数与非UI线程调用:
单条曲线绘图测试效果:
最终测试效果:
遇到的问题:
在非UI线程调用UI控件:
整体测试工程下载:
控件摆放与使用控件大致介绍:
控件最终摆放效果如下图:
大致使用了如下控件:
TableLayoutPanel 自动排列组件,辅助实现控件与窗体的同步缩放
group 控件分组组件,与TableLayoutPanel 组合使用
ZedGraphControl 图表组件,用于绘图
serialPort 串口组件
checkbox 选框
下载必要的Nuget程序包:
这次编写的串口绘图助手程序需要以下几个Nuget程序包的支持:
图表绘制相关代码逻辑:
这一板块部分主要想实现的是:
1. 绘制Y轴是数据,X轴是1ms级别时间戳的曲线
2. 能绘制不止一条曲线,且每条曲线颜色可以自定
3. 曲线是由点集构成,因此需要标注每个数据点
4. 鼠标移动到图表曲线的点上显示点的X,Y精确坐标
字典存储每条曲线的 PointPairList:
先定义全局变量,来存储每条曲线的列表浮点数据值:
// 有一个全局或类级别的字典来存储每条曲线的PointPairList private Dictionary<string, PointPairList> curves = new Dictionary<string, PointPairList>();
绘制/更新曲线函数的编写:
这个自定义的函数只需传入以下几个参数:
ZedGraph.ZedGraphControl zgc:图表控件名称
string label : 曲线名称
double x, double y : x,y坐标
Color color : 曲线颜色
实现了有曲线就继续绘制,没曲线就根据传参数的曲线名称,创建一条曲线
// 初始化或更新曲线(如果曲线不存在,则创建它),并标记每个点 // 使用圆形(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 蓝色,按键每次按下将全局变量数值增加,绘制到曲线上
//绘制测试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轴数值看其随着时间变化就行了:
先定义一些必要的变量如下:
// 声明一个DateTime变量来存储系统时间 private DateTime firstDataPointTime = DateTime.MinValue; // 初始化为一个不可能的值 (这个变量用于获取第一个变量出现时的时间)double timeStampInMilliseconds; //毫秒级计数bool firstDataPoint_flag = false; //记录是否获取了第一个数据DateTime newDataPointTime; // 新时间
然后将获取时间的逻辑加入测试按钮:
//绘制测试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);}}
时间戳绘制效果如下:
如图,我点击绘制测试按钮,越快线条越急抖,越慢线条越平缓
清空图表按钮实现:
这个没啥好注意的,主要是我实现的过程中忘记清空曲线字典列表了,导致清空一次后没法在此生成曲线:
//清空图表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是毫秒,后面的小数点就是微秒数
串口接收事件生成与衔接图表逻辑:
对于检测端口卡顿、打开串口、发送数据等操作在之前的 串口助手窗体程序的制作中就已经实现了,本文主要讲串口接收部分怎么去衔接图表的逻辑。
单片机方面宏定义打印函数:
这里为了方便用户使用,进行了数据发送相关的宏定义:
// 宏定义的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)
单片机串口发送示例:
以下截图示例了如何用单片机串口发送你想要绘制的曲线、曲线颜色以及数据:
最终发送出去的数据会是这样的格式:
接收数据字符串字段函数:
这需要先定义一些全局变量进行接收数据字符串字段等:
string title;//串口获取的曲线名称string color;//串口获取的颜色字段double value;//串口接受的数据
private 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的事件(黄色图表
)中打开启用的:
然后这一步不是最终实现,只是阶段性测试接收与解算是否成功:
//串口接收逻辑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属性,因此需要定义字典来转换一下:
// 创建一个颜色字典 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},// 可以根据需要添加更多颜色 };
可以如下方式来使用字典:
Color 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
控件的函数:private void DrawNewPoint(ZedGraph.ZedGraphControl zgc, string label, double x, double y, Color color)
//这是在非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);} }
然后就可以使用这个线程委托来调用数据绘图了:
//数据画图函数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,以此循环
并发送的程序:
然后实测效果符合情况:
最终测试效果:
这次测试比之前的阶段测试添加了一条变量的曲线来绘制,它与之前的变量变换情况相反:
也是符合情况的:
最后提一嘴:别忘记这个单片机端的宏定义:
// 宏定义的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