简介:这份资源面向使用 C# WinForm 与 Chart 控件进行数据可视化的开发者,重点解决同一图表中多个 Y 轴同时显示、不同量级数据系列难以共图呈现的问题。内容围绕 ChartArea 与 Series 的关联机制展开,涵盖多区域创建、各 Y 轴范围与刻度配置、轴位置布局调整以及数据点加载刷新等完整实现思路,适合具备一定 C# 与 .NET Framework 基础、需要处理多量级曲线对比的读者参考。压缩包共 117 个文件,约 21.19MB,以 cs 源码、dll 依赖库、xml 配置、resx 资源、config 配置文件为主,另含 sln 解决方案、csproj 工程文件及少量图片与表格,工程结构完整,可直接打开运行调试。目前已有 2755 人学习下载,可作为多 Y 轴图表开发的实践样例,帮助读者快速理解轴布局与系列绑定的关键细节,并在此基础上扩展颜色、标签与网格线等个性化设置。
1. 多 Y 轴同屏:当一条曲线不够用的时候
做过工业上位机、医疗波形、金融行情或者实验数据采集的人,迟早会撞上同一个需求:一张图里要同时看温度、压力、流量三条曲线,可它们的量纲差着好几个数量级——温度在 20 到 80 之间晃,压力是 0.1 到 0.5 兆帕,流量直接飙到 3000 以上。全塞进一个 Y 轴,温度那条线就贴着 X 轴成了一条直线,什么趋势都看不出来。这时候唯一的出路就是多 Y 轴同屏显示,让每条曲线有自己的刻度尺,共享同一条时间轴。
C# 里做这件事,主流就两条路:WinForms 时代的 Chart 控件,和 WPF 时代的 LiveCharts、OxyPlot 这类第三方库。标题里说的「Y 轴多个同时显示全部代码」,核心诉求其实很明确——不是问能不能做,而是想要一份能直接跑起来、多个 Y 轴各管各的、图例和缩放都不打架的完整实现。这篇就按这个思路走,从选型讲到能复现的代码,再把我踩过的坑摊开说。
2. 选型先定死:Chart 控件、LiveCharts、OxyPlot 到底用哪个
2.1 三种方案的能力边界对比
多 Y 轴这个需求,不同库的支持程度差别很大,选错了后面全是返工。我一般先看三个维度:多轴是原生支持还是要自己画、实时刷新扛不扛得住、授权干不干净。
| 方案 | 多 Y 轴支持方式 | 实时刷新表现 | 授权 | 适合场景 |
|---|---|---|---|---|
| WinForms Chart | 原生,SecondaryAxis 挂载 | 中等,数据点过万多会卡 | 随 .NET 免费 | 传统工控上位机、报表 |
| LiveCharts2 | 原生,多 Axis 集合 | 较好,有动画但可关 | MIT | WPF 新项目、仪表盘 |
| OxyPlot | 原生,多 YAxis + Key 绑定 | 好,轻量 | MIT | 跨平台、科学绘图 |
WinForms Chart 的优势是零依赖,Visual Studio 工具箱拖出来就能用,缺点是样式老、刷新性能一般。LiveCharts2 的 API 现代、动画顺滑,但默认动画在实时场景下反而是负担,得手动关。OxyPlot 最「素」,但多轴绑定靠 Key,逻辑最清晰,跨平台也稳。
2.2 多 Y 轴的本质:轴和系列要建立绑定关系
很多人第一次做多 Y 轴会翻车,是因为没理解一件事:多 Y 轴不是把轴画出来就完事,而是要让每个数据系列明确知道自己归哪根轴管。轴本身只是个刻度尺,系列才是数据。绑定关系建立不起来,就会出现「轴画了三条,曲线全挤在第一条轴上」的玄学现象。
在 WinForms Chart 里,这个绑定靠Series.YAxisType指定轴名称;在 OxyPlot 里靠Series.YAxisKey匹配YAxis.Key;在 LiveCharts2 里靠Series.ScalesYAt指定轴索引。三种库叫法不同,逻辑一模一样。
2.3 一个最小可跑的 WinForms 多 Y 轴骨架
先给一份能直接跑的最小实现,用 WinForms Chart,三个 Y 轴分别管温度、压力、流量。新建一个 WinForms 项目,窗体上拖一个 Chart 控件命名chart1,然后:
using System; using System.Drawing; using System.Windows.Forms; using System.Windows.Forms.DataVisualization.Charting; namespace MultiAxisDemo { public partial class MainForm : Form { public MainForm() { InitializeComponent(); SetupMultiAxisChart(); FeedDemoData(); } private void SetupMultiAxisChart() { chart1.Series.Clear(); chart1.ChartAreas.Clear(); chart1.Legends.Clear(); // 一个绘图区,承载三根 Y 轴 ChartArea area = new ChartArea("main"); area.AxisX.LabelStyle.Format = "HH:mm:ss"; area.AxisX.MajorGrid.LineColor = Color.LightGray; chart1.ChartAreas.Add(area); // 主轴 Y1:温度,放左侧 area.AxisY.Title = "温度 (℃)"; area.AxisY.Minimum = 0; area.AxisY.Maximum = 100; area.AxisY.LabelStyle.ForeColor = Color.Red; area.AxisY.TitleForeColor = Color.Red; // 副轴 Y2:压力,放右侧 Axis y2 = new Axis(); y2.Name = "AxisPressure"; y2.Title = "压力 (MPa)"; y2.Minimum = 0; y2.Maximum = 1; y2.MajorGrid.Enabled = false; // 副轴不画网格,避免和主轴打架 y2.LabelStyle.ForeColor = Color.Blue; y2.TitleForeColor = Color.Blue; y2.MajorTickMark.Enabled = true; area.AxisY2 = y2; // 关键:挂到 AxisY2 // 第三根轴:流量,用第二副轴位置 Axis y3 = new Axis(); y3.Name = "AxisFlow"; y3.Title = "流量 (L/h)"; y3.Minimum = 0; y3.Maximum = 5000; y3.MajorGrid.Enabled = false; y3.LabelStyle.ForeColor = Color.Green; y3.TitleForeColor = Color.Green; area.AxisY3 = y3; // 关键:挂到 AxisY3 Legend legend = new Legend("mainLegend"); legend.Docking = Docking.Top; chart1.Legends.Add(legend); } private void FeedDemoData() { // 温度系列,绑主轴 Series temp = new Series("温度"); temp.ChartType = SeriesChartType.Line; temp.YAxisType = AxisType.Primary; temp.Color = Color.Red; temp.BorderWidth = 2; // 压力系列,绑副轴 AxisPressure Series pressure = new Series("压力"); pressure.ChartType = SeriesChartType.Line; pressure.YAxisType = AxisType.Secondary; pressure.Color = Color.Blue; pressure.BorderWidth = 2; // 流量系列,绑第三轴 AxisFlow Series flow = new Series("流量"); flow.ChartType = SeriesChartType.Line; flow.YAxisType = AxisType.Secondary; // 注意这里 flow.Color = Color.Green; flow.BorderWidth = 2; chart1.Series.Add(temp); chart1.Series.Add(pressure); chart1.Series.Add(flow); DateTime start = DateTime.Now.AddMinutes(-30); Random rnd = new Random(42); for (int i = 0; i < 60; i++) { temp.Points.AddXY(start.AddSeconds(i * 30), 40 + rnd.NextDouble() * 20); pressure.Points.AddXY(start.AddSeconds(i * 30), 0.3 + rnd.NextDouble() * 0.2); flow.Points.AddXY(start.AddSeconds(i * 30), 2000 + rnd.NextDouble() * 1500); } } } }这段代码里最关键的三行是area.AxisY2 = y2、area.AxisY3 = y3和系列的YAxisType。轴挂到 ChartArea 上,系列再通过YAxisType找到对应的轴。这里有个容易忽略的点:WinForms Chart 的AxisType枚举只有Primary和Secondary两个值,第三根轴虽然挂在AxisY3上,但系列的YAxisType只能写Secondary,实际绑定靠的是轴在 ChartArea 上的挂载顺序。这是这个控件的历史遗留设计,第一次用的人基本都会在这里卡一下。
参数上,MajorGrid.Enabled = false是必须的。三根轴如果都画网格线,图面会变成一张密网,什么都看不清。惯例是只让主轴画网格,副轴只留刻度和标签。
3. 把多 Y 轴做成能实时刷新的完整实现
3.1 实时刷新为什么会让多 Y 轴崩掉
静态图跑通只是第一步,真正难的是实时刷新。工业场景里数据每秒来一批,如果每来一个点就Points.AddXY然后chart1.Invalidate(),跑几分钟界面就开始卡,再久一点直接假死。原因有两个:一是数据点无限累积,二是每次刷新都触发全量重绘。
多 Y 轴会放大这个问题,因为三根轴各自要重新计算刻度范围。如果轴的Minimum和Maximum写死了还好,一旦开了自动缩放,每帧都要遍历全部数据点求极值,开销直接翻三倍。
我的做法是固定轴范围加滑动窗口。轴范围根据量程写死,数据只保留最近 N 个点,超出的从头部移除。这样每帧的计算量是常数,跟运行时长无关。
3.2 滑动窗口 + 定时刷新的完整代码
using System; using System.Collections.Generic; using System.Drawing; using System.Windows.Forms; using System.Windows.Forms.DataVisualization.Charting; namespace MultiAxisRealtime { public partial class RealtimeForm : Form { private const int MaxPoints = 300; // 每系列保留的最大点数 private const int SampleIntervalMs = 200; // 采样间隔 private readonly Timer _timer = new Timer(); private readonly Random _rnd = new Random(); private DateTime _cursor = DateTime.Now; public RealtimeForm() { InitializeComponent(); BuildChart(); _timer.Interval = SampleIntervalMs; _timer.Tick += OnTick; _timer.Start(); } private void BuildChart() { chart1.Series.Clear(); chart1.ChartAreas.Clear(); ChartArea area = new ChartArea("rt"); area.AxisX.LabelStyle.Format = "HH:mm:ss"; area.AxisX.MajorGrid.LineColor = Color.Gainsboro; area.AxisX.IsMarginVisible = false; chart1.ChartAreas.Add(area); area.AxisY.Title = "温度 (℃)"; area.AxisY.Minimum = 0; area.AxisY.Maximum = 100; area.AxisY.LabelStyle.ForeColor = Color.Red; Axis y2 = new Axis { Name = "P", Title = "压力 (MPa)", Minimum = 0, Maximum = 1, MajorGrid = { Enabled = false }, LabelStyle = { ForeColor = Color.Blue } }; area.AxisY2 = y2; Axis y3 = new Axis { Name = "F", Title = "流量 (L/h)", Minimum = 0, Maximum = 5000, MajorGrid = { Enabled = false }, LabelStyle = { ForeColor = Color.Green } }; area.AxisY3 = y3; AddSeries("温度", AxisType.Primary, Color.Red); AddSeries("压力", AxisType.Secondary, Color.Blue); AddSeries("流量", AxisType.Secondary, Color.Green); } private void AddSeries(string name, AxisType axis, Color color) { Series s = new Series(name) { ChartType = SeriesChartType.FastLine, // 实时场景用 FastLine YAxisType = axis, Color = color, BorderWidth = 2, XValueType = ChartValueType.DateTime }; chart1.Series.Add(s); } private void OnTick(object sender, EventArgs e) { _cursor = _cursor.AddMilliseconds(SampleIntervalMs); double t = 40 + 15 * Math.Sin(_cursor.TimeOfDay.TotalSeconds / 10.0) + _rnd.NextDouble() * 2; double p = 0.4 + 0.1 * Math.Sin(_cursor.TimeOfDay.TotalSeconds / 7.0) + _rnd.NextDouble() * 0.02; double f = 2500 + 800 * Math.Sin(_cursor.TimeOfDay.TotalSeconds / 13.0) + _rnd.NextDouble() * 50; AppendPoint("温度", t); AppendPoint("压力", p); AppendPoint("流量", f); // 只让 X 轴跟随最新时间,Y 轴范围固定不动 chart1.ChartAreas[0].AxisX.Minimum = _cursor.AddSeconds(-30).ToOADate(); chart1.ChartAreas[0].AxisX.Maximum = _cursor.ToOADate(); } private void AppendPoint(string seriesName, double y) { Series s = chart1.Series[seriesName]; s.Points.AddXY(_cursor, y); while (s.Points.Count > MaxPoints) { s.Points.RemoveAt(0); } } } }逻辑上分三块。BuildChart负责一次性把三根轴和三个系列建好,之后不再动结构。OnTick是定时器回调,每 200 毫秒生成一批模拟数据并追加。AppendPoint做滑动窗口,超过MaxPoints就从头部删。
参数说明:MaxPoints = 300配合 200 毫秒间隔,正好覆盖 60 秒的窗口,和 X 轴 30 秒的显示范围留了余量。SeriesChartType.FastLine比普通Line少了标记点和部分样式计算,实时场景下帧率能明显好一截。X 轴用ToOADate()转成 double,这是 Chart 控件处理时间轴的标准做法,直接传DateTime在部分版本上会有精度问题。
注意:
Points.RemoveAt(0)在点数很多时是 O(n) 操作。300 个点无所谓,如果窗口开到几万点,得换成环形缓冲或者直接重建系列,否则删除本身就成了瓶颈。
3.3 轴范围自动跟随的取舍
有人会问,能不能让 Y 轴也跟着数据自动缩放。能,但要看场景。如果是观察长期趋势,固定范围更稳,因为轴一缩放,视觉上的「平稳」和「剧烈波动」就分不清了。如果是调试阶段想看清细节,可以开自动缩放,但一定要给每根轴设IsStartedFromZero = false和合理的Interval,否则刻度会跳得很碎。
我的习惯是:生产环境固定范围,调试模式加一个开关切自动。自动缩放的实现就是在AppendPoint之后遍历当前窗口内的点求极值,再留 10% 余量赋给Minimum和Maximum。这段逻辑简单,但每帧都要跑,点数多的时候要评估开销。
4. 多 Y 轴避坑:五个我真实踩过的坑
4.1 曲线全挤在第一根轴上
现象:轴画了三根,标签颜色也对,但三条曲线全贴着主轴走,副轴上的曲线位置明显不对。
原因:系列的YAxisType没设,或者设了Secondary但 ChartArea 上根本没挂AxisY2。控件找不到目标轴时会静默回退到主轴,不报错。
解决:先确认area.AxisY2和area.AxisY3都赋了值,再逐个检查系列的YAxisType。第三根轴的系列写Secondary是正常的,绑定靠挂载顺序,别去改枚举值。
4.2 副轴网格线和主轴重叠成网
现象:图面上一片密密麻麻的横线,数据趋势完全看不出来。
原因:每根轴默认都开MajorGrid,三根轴的网格线叠在一起。
解决:只保留主轴的网格,副轴统一MajorGrid.Enabled = false。如果主轴网格也想弱化,把LineColor调成浅灰、LineDashStyle设成Dash。
4.3 实时刷新跑十分钟后界面卡死
现象:刚启动很流畅,运行一段时间后刷新越来越慢,最后无响应。
原因:数据点无限累积,每次重绘要处理全部历史点,内存和 CPU 双双爆掉。
解决:滑动窗口,固定最大点数。同时把ChartType换成FastLine,关掉不必要的动画和标记。如果还卡,考虑把Chart换成OxyPlot或LiveCharts2,它们的渲染管线更轻。
4.4 时间轴刻度显示成数字
现象:X 轴标签显示的是 45000 这种数字,不是时间。
原因:XValueType没设成DateTime,控件把 OADate 当普通数值处理了。
解决:系列上设XValueType = ChartValueType.DateTime,ChartArea 的AxisX.LabelStyle.Format设成"HH:mm:ss"。两个都要设,缺一个都不行。
4.5 图例颜色和曲线对不上
现象:图例里三条线的颜色和图上实际曲线颜色不一致。
原因:图例默认取系列的第一个数据点颜色,如果系列用了FastLine或者自定义了BackColor,图例可能取错。
解决:显式设Series.Color,并且确认Legend的IsTextAutoFit和Docking不会导致重排。如果还不对,手动给图例项设LegendItem的颜色。
5. 进阶:把多 Y 轴封装成可复用的控件
5.1 封装思路:轴配置和系列配置分离
项目里如果多处要用多 Y 轴,每次都手写一遍AxisY2、AxisY3是灾难。我的做法是抽一个MultiAxisChart用户控件,对外暴露两个集合:轴配置和系列配置。调用方只描述「我要几根轴、每根轴什么量程、哪个系列归哪根轴」,内部负责建轴、绑系列、管刷新。
public class AxisConfig { public string Key { get; set; } // 轴标识 public string Title { get; set; } // 轴标题 public double Min { get; set; } public double Max { get; set; } public Color Color { get; set; } public bool ShowGrid { get; set; } } public class SeriesConfig { public string Name { get; set; } public string AxisKey { get; set; } // 绑到哪根轴 public Color Color { get; set; } public int MaxPoints { get; set; } = 300; }封装时有个细节要处理:WinForms Chart 只有AxisY、AxisY2、AxisY3三个固定槽位,超过三根轴就没地方挂了。如果业务真需要四根以上,要么换 OxyPlot(它的YAxis是集合,想加几根加几根),要么把不常用的轴做成可切换显示。我一般建议超过三根就上 OxyPlot,别在 Chart 控件上硬撑。
5.2 验证多 Y 轴是否真的绑对了
写完别急着接真实数据,先用一组已知量程的模拟数据验证。方法很简单:给每根轴设一个差异极大的量程,比如轴一 0 到 1,轴二 0 到 1000,轴三 0 到 1000000,然后每个系列都喂一条恒定值曲线。如果绑定正确,三条线应该分别落在各自轴的中间位置,视觉上高度一致。如果哪条线贴边或者跑出可视区,就是绑错了。
这个验证方法我每次做多轴都会跑一遍,比盯着代码看快得多。轴绑定这种事,肉眼验证比逻辑推演可靠。
5.3 一个我改不掉的习惯
做多 Y 轴这些年,我养成一个习惯:任何多轴图,第一件事是把每根轴的Minimum和Maximum显式写死,哪怕后面要开自动缩放,也先写死跑通再说。因为自动缩放会掩盖绑定错误——轴范围跟着数据跑,曲线看起来「好像在哪根轴上都能对上」,等发现问题时已经查了半天。
固定范围跑通,再逐个开自动,这个顺序能省下大量排查时间。多 Y 轴的坑大多不在代码本身,而在绑定关系和刷新策略上,先把静态的绑对,再谈动态的优化。希望帮到你。
本文还有配套的精品资源,点击获取