2026-05-20
C#
0

目录

🎯 折线图看趋势,蜡烛图看"过程"
🔍 问题深度剖析:工业数据可视化的三个常见误区
误区一:用均值代替过程
误区二:时间粒度选择不当
误区三:忽视"实体"与"影线"的信息量
💡 核心要点提炼
🛠️ 方案一:基础蜡烛图——液压泵压力周期分析
场景描述
踩坑预警
🛠️ 方案二:多系列叠加——压力 + 温度双指标对比
🛠️ 方案三:动态追加数据 + 告警线标注
⚠️ 常见问题汇总
📊 性能参考数据
💬 技术讨论
🎯 总结
#WinForms #LiveCharts2 #工业数据可视化 #蜡烛图 #数据分析

🎯 折线图看趋势,蜡烛图看"过程"

做工业数据分析的时候,折线图是最常见的选择。但折线图有一个先天缺陷:它只能展示某一时刻的单一数值,丢失了这段时间内数据的波动过程

以设备压力监控为例,某台液压泵在某个小时内的压力均值是 4.2MPa,折线图上就是一个点。但这个小时里,压力可能从 3.8MPa 冲到 5.1MPa 又回落到 4.0MPa——这个波动过程完全被均值抹平了。如果设备的安全阈值是 5.0MPa,这次超限在折线图上根本看不出来。

蜡烛图(Candlestick)最初用于金融领域,但它的数据结构——开盘值、收盘值、最高值、最低值——天然契合工业数据的周期性统计需求。把这四个维度换成"周期起始值、周期结束值、周期最大值、周期最小值",一张蜡烛图就能同时展示数值的趋势方向、波动幅度和极值范围。

本文基于 LiveCharts 2(LiveChartsCore.SkiaSharpView.WinForms),从零到一实现:

  • ✅ 基础蜡烛图搭建(CandlesticksSeries
  • ✅ 工业数据映射方案(压力 / 温度 / 电流周期统计)
  • ✅ 实时数据动态追加与告警标注

🔍 问题深度剖析:工业数据可视化的三个常见误区

误区一:用均值代替过程

很多监控系统每分钟采集一次数据,然后把这一分钟内的均值存入数据库,再用折线图展示。均值掩盖了极值,而设备故障恰恰发生在极值处。蜡烛图强制你在数据聚合阶段同时记录 Open/Close/High/Low 四个值,这本身就是一种更完整的数据采集规范。

误区二:时间粒度选择不当

折线图在数据点密集时会变成一团乱麻,稀疏时又看不出规律。蜡烛图通过调整周期(5分钟、1小时、1班次)来控制信息密度,同一份原始数据,不同粒度的蜡烛图能揭示不同层次的规律:5分钟图看操作响应,1小时图看负载变化,班次图看产能趋势。

误区三:忽视"实体"与"影线"的信息量

蜡烛图的矩形实体(Open 到 Close)反映的是主趋势方向,上下影线(High 和 Low 超出实体的部分)反映的是瞬时波动强度。影线特别长说明这个周期内数据极不稳定,即使均值正常也值得警惕。这个信息在折线图里完全丢失了。


💡 核心要点提炼

LiveCharts 2 的蜡烛图使用 CandlesticksSeries<FinancialPoint>,数据类型是 FinancialPoint,构造函数为:

csharp
new FinancialPoint(DateTime date, double high, double open, double close, double low)

注意参数顺序:High 在 Open 之前,这和直觉不太一样,初次使用很容易搞错导致图形显示异常。

颜色规则与金融蜡烛图一致:Close > Open 时显示上涨色(默认绿色),Close < Open 时显示下跌色(默认红色)。在工业场景里,这对应"周期末值高于起始值"和"低于起始值",可以直观反映数据的变化方向。

X 轴类型需要配合 DateTimeAxis,否则时间标签无法正确显示。


🛠️ 方案一:基础蜡烛图——液压泵压力周期分析

场景描述

模拟液压泵每小时的压力数据,每小时采集 3600 个采样点,聚合为一根蜡烛。展示过去 24 小时的压力变化趋势与波动情况。

csharp
using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart17 { public partial class Form1 : Form { public Form1() { InitializeComponent(); InitCandlestickChart(); } private void InitCandlestickChart() { Text = "液压泵压力分析 - 蜡烛图(每小时聚合)"; Size = new System.Drawing.Size(1000, 500); var pressureData = GeneratePressureData(hours: 24); var candleSeries = new CandlesticksSeries<FinancialPoint> { Values = pressureData, Name = "液压压力(MPa)", UpFill = new SolidColorPaint(new SKColor(33, 150, 243, 120)), UpStroke = new SolidColorPaint(new SKColor(33, 150, 243)) { StrokeThickness = 1.5f }, DownFill = new SolidColorPaint(new SKColor(255, 152, 0, 120)), DownStroke = new SolidColorPaint(new SKColor(255, 152, 0)) { StrokeThickness = 1.5f } }; var xAxis = new Axis { Name = "时间", LabelsRotation = 30, // ✅ 关键修复:对非法值做保护,避免 ArgumentOutOfRangeException Labeler = value => { // LiveCharts 在计算轴边界时可能传入超范围的浮点值 // 必须在转换前做合法性检查 if (double.IsNaN(value) || double.IsInfinity(value)) return string.Empty; long ticks = (long)value; if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks) return string.Empty; return new DateTime(ticks).ToString("MM/dd HH:mm"); }, UnitWidth = TimeSpan.FromHours(1).Ticks, MinStep = TimeSpan.FromHours(1).Ticks }; var yAxis = new Axis { Name = "压力(MPa)", MinLimit = 2.0, MaxLimit = 6.5, Labeler = v => $"{v:F1} MPa" }; var chart = new CartesianChart { Dock = DockStyle.Fill, Series = new ISeries[] { candleSeries }, XAxes = new[] { xAxis }, YAxes = new[] { yAxis } }; Controls.Add(chart); } private static List<FinancialPoint> GeneratePressureData(int hours) { var rng = new Random(42); var result = new List<FinancialPoint>(); // 用固定基准时间 var baseTime = new DateTime(2026, 4, 30, 0, 0, 0); for (int h = 0; h < hours; h++) { var timestamp = baseTime.AddHours(h); double baseP = 3.8 + Math.Sin(h * 0.4) * 0.5; double open = baseP + rng.NextDouble() * 0.3 - 0.15; double close = baseP + rng.NextDouble() * 0.3 - 0.15; double high = Math.Max(open, close) + rng.NextDouble() * 0.6; double low = Math.Min(open, close) - rng.NextDouble() * 0.4; if (h >= 14 && h <= 18) { open += 0.8; close += 0.8; high += 1.0; low += 0.5; } // FinancialPoint 参数顺序:date, high, open, close, low result.Add(new FinancialPoint( timestamp, Math.Round(high, 2), Math.Round(open, 2), Math.Round(close, 2), Math.Round(low, 2) )); } return result; } } }

image.png

踩坑预警

FinancialPoint 的参数顺序是 (date, high, open, close, low)High 排在第二位,Open 第三位。如果写成 (date, open, high, close, low) 不会报错,但图形会显示异常——蜡烛实体可能跑到影线外面,看起来像一堆乱线。建议在代码里加注释强调这一点。


🛠️ 方案二:多系列叠加——压力 + 温度双指标对比

工业设备通常需要同时监控多个参数的联动关系,比如压力升高时温度是否同步上升(可能意味着密封泄漏)。两个蜡烛系列叠加在同一图表上,可以直观观察这种联动。

csharp
using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart17 { public partial class Form2 : Form { // ✅ 两个方法共用同一基准时间,确保 X 轴对齐 private static readonly DateTime BaseTime = new DateTime(2026, 4, 30, 0, 0, 0); public Form2() { InitializeComponent(); InitDualSeriesChart(); } private void InitDualSeriesChart() { Text = "设备多参数分析 - 压力与温度联动"; Size = new System.Drawing.Size(1100, 550); var pressureData = GeneratePressureData(hours: 24); var temperatureData = GenerateTemperatureData(hours: 24); var pressureSeries = new CandlesticksSeries<FinancialPoint> { Values = pressureData, Name = "压力(MPa)", UpFill = new SolidColorPaint(new SKColor(33, 150, 243, 100)), UpStroke = new SolidColorPaint(new SKColor(33, 150, 243)) { StrokeThickness = 1.5f }, DownFill = new SolidColorPaint(new SKColor(255, 152, 0, 100)), DownStroke = new SolidColorPaint(new SKColor(255, 152, 0)) { StrokeThickness = 1.5f }, ScalesYAt = 0 }; var tempSeries = new CandlesticksSeries<FinancialPoint> { Values = temperatureData, Name = "温度(°C)", UpFill = new SolidColorPaint(new SKColor(76, 175, 80, 100)), UpStroke = new SolidColorPaint(new SKColor(76, 175, 80)) { StrokeThickness = 1.5f }, DownFill = new SolidColorPaint(new SKColor(244, 67, 54, 100)), DownStroke = new SolidColorPaint(new SKColor(244, 67, 54)) { StrokeThickness = 1.5f }, ScalesYAt = 1 }; var xAxis = new Axis { LabelsRotation = 30, Labeler = value => { if (double.IsNaN(value) || double.IsInfinity(value)) return string.Empty; long ticks = (long)value; if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks) return string.Empty; return new DateTime(ticks).ToString("MM/dd HH:mm"); }, UnitWidth = TimeSpan.FromHours(1).Ticks, MinStep = TimeSpan.FromHours(1).Ticks }; var yAxisPressure = new Axis { Name = "压力(MPa)", Position = LiveChartsCore.Measure.AxisPosition.Start, MinLimit = 2.0, MaxLimit = 7.0, Labeler = v => $"{v:F1} MPa" }; var yAxisTemp = new Axis { Name = "温度(°C)", Position = LiveChartsCore.Measure.AxisPosition.End, MinLimit = 40, MaxLimit = 120, Labeler = v => $"{v:F0}°C" }; var chart = new CartesianChart { Dock = DockStyle.Fill, Series = new ISeries[] { pressureSeries, tempSeries }, XAxes = new[] { xAxis }, YAxes = new[] { yAxisPressure, yAxisTemp }, LegendPosition = LiveChartsCore.Measure.LegendPosition.Top }; Controls.Add(chart); } private static List<FinancialPoint> GeneratePressureData(int hours) { var rng = new Random(42); var result = new List<FinancialPoint>(); for (int h = 0; h < hours; h++) { var timestamp = BaseTime.AddHours(h); double baseP = 3.8 + Math.Sin(h * 0.4) * 0.5; double open = baseP + rng.NextDouble() * 0.3 - 0.15; double close = baseP + rng.NextDouble() * 0.3 - 0.15; double high = Math.Max(open, close) + rng.NextDouble() * 0.6; double low = Math.Min(open, close) - rng.NextDouble() * 0.4; if (h >= 14 && h <= 18) { open += 0.8; close += 0.8; high += 1.0; low += 0.5; } result.Add(new FinancialPoint( timestamp, Math.Round(high, 2), Math.Round(open, 2), Math.Round(close, 2), Math.Round(low, 2) )); } return result; } private static List<FinancialPoint> GenerateTemperatureData(int hours) { var rng = new Random(99); var result = new List<FinancialPoint>(); for (int h = 0; h < hours; h++) { var timestamp = BaseTime.AddHours(h); // ✅ 与压力数据时间对齐 double baseT = 65 + Math.Sin(h * 0.35) * 8; double open = baseT + rng.NextDouble() * 4 - 2; double close = baseT + rng.NextDouble() * 4 - 2; double high = Math.Max(open, close) + rng.NextDouble() * 6; double low = Math.Min(open, close) - rng.NextDouble() * 4; if (h >= 14 && h <= 18) { open += 12; close += 12; high += 15; low += 8; } result.Add(new FinancialPoint( timestamp, Math.Round(high, 1), Math.Round(open, 1), Math.Round(close, 1), Math.Round(low, 1) )); } return result; } } }

image.png

双 Y 轴的关键是 ScalesYAt 属性——0 绑定左轴,1 绑定右轴,两个系列用各自的量纲独立缩放,不会互相干扰。这是工业多参数对比场景里用得最多的配置。


🛠️ 方案三:动态追加数据 + 告警线标注

实时监控场景下,数据每隔固定周期追加一根新蜡烛,同时需要在图表上标注安全阈值线,超限的蜡烛用醒目颜色高亮。

csharp
using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.Painting.Effects; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart17 { public partial class Form3 : Form { private CandlesticksSeries<FinancialPoint> _series; private List<FinancialPoint> _data; private System.Windows.Forms.Timer _timer; private readonly Random _rng = new Random(); private const double PRESSURE_LIMIT = 5.0; private static readonly DateTime BaseTime = new DateTime(2026, 4, 30, 0, 0, 0); private static string SafeDateLabeler(double value) { if (double.IsNaN(value) || double.IsInfinity(value)) return string.Empty; long ticks = (long)value; if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks) return string.Empty; return new DateTime(ticks).ToString("MM/dd HH:mm"); } public Form3() { InitializeComponent(); InitRealtimeChart(); } private static List<FinancialPoint> GeneratePressureData(int hours) { var rng = new Random(42); var result = new List<FinancialPoint>(); for (int h = 0; h < hours; h++) { var timestamp = BaseTime.AddHours(h); double baseP = 3.8 + Math.Sin(h * 0.4) * 0.5; double open = baseP + rng.NextDouble() * 0.3 - 0.15; double close = baseP + rng.NextDouble() * 0.3 - 0.15; double high = Math.Max(open, close) + rng.NextDouble() * 0.6; double low = Math.Min(open, close) - rng.NextDouble() * 0.4; if (h >= 14 && h <= 18) { open += 0.8; close += 0.8; high += 1.0; low += 0.5; } result.Add(new FinancialPoint( timestamp, Math.Round(high, 2), Math.Round(open, 2), Math.Round(close, 2), Math.Round(low, 2) )); } return result; } private void InitRealtimeChart() { Text = "实时压力监控 - 蜡烛图动态追加"; Size = new System.Drawing.Size(1000, 500); _data = GeneratePressureData(hours: 12); _series = new CandlesticksSeries<FinancialPoint> { Values = _data, Name = "压力(MPa)", UpFill = new SolidColorPaint(new SKColor(33, 150, 243, 120)), UpStroke = new SolidColorPaint(new SKColor(33, 150, 243)) { StrokeThickness = 1.5f }, DownFill = new SolidColorPaint(new SKColor(255, 152, 0, 120)), DownStroke = new SolidColorPaint(new SKColor(255, 152, 0)) { StrokeThickness = 1.5f } }; var xAxis = new Axis { LabelsRotation = 30, Labeler = SafeDateLabeler, UnitWidth = TimeSpan.FromHours(1).Ticks, MinStep = TimeSpan.FromHours(1).Ticks }; var yAxis = new Axis { Name = "压力(MPa)", MinLimit = 2.0, MaxLimit = 7.0, Labeler = v => $"{v:F1} MPa" }; var alertSection = new RectangularSection { Yi = PRESSURE_LIMIT, Yj = 7.0, Fill = new SolidColorPaint(new SKColor(244, 67, 54, 40)), Stroke = new SolidColorPaint(new SKColor(244, 67, 54)) { StrokeThickness = 1.5f, PathEffect = new DashEffect(new float[] { 6, 4 }) } }; var chart = new CartesianChart { Dock = DockStyle.Fill, Series = new ISeries[] { _series }, XAxes = new[] { xAxis }, YAxes = new[] { yAxis }, Sections = new[] { alertSection } }; Controls.Add(chart); _timer = new System.Windows.Forms.Timer { Interval = 3000 }; _timer.Tick += (s, e) => AppendNewCandle(); _timer.Start(); } private void AppendNewCandle() { if (_data.Count == 0) return; var nextTime = _data.Last().Date.AddHours(1); double baseP = 3.8 + _rng.NextDouble() * 0.8; double open = baseP + _rng.NextDouble() * 0.3 - 0.15; double close = baseP + _rng.NextDouble() * 0.3 - 0.15; double high = Math.Max(open, close) + _rng.NextDouble() * 0.8; double low = Math.Min(open, close) - _rng.NextDouble() * 0.4; var newPoint = new FinancialPoint( nextTime, Math.Round(high, 2), Math.Round(open, 2), Math.Round(close, 2), Math.Round(low, 2) ); _data.Add(newPoint); if (_data.Count > 48) _data.RemoveAt(0); _series.Values = new List<FinancialPoint>(_data); } protected override void OnFormClosed(FormClosedEventArgs e) { _timer?.Stop(); _timer?.Dispose(); base.OnFormClosed(e); } } }

image.png

RectangularSection 是 LiveCharts 2 里添加参考区域的标准方式,YiYj 分别是区域的下边界和上边界,配合 DashEffect 可以画出虚线告警线,视觉效果比单纯的折线更清晰。


⚠️ 常见问题汇总

问题一:蜡烛图显示异常,实体跑到影线外

99% 是 FinancialPoint 参数顺序写错了。正确顺序是 (date, high, open, close, low),务必确认 High 是第二个参数。

问题二:X 轴显示数字而不是时间

需要配置 Labelerv => new DateTime((long)v).ToString("HH:mm"),同时设置 UnitWidth = TimeSpan.FromHours(1).Ticks,否则 X 轴的刻度间隔计算会错乱。

问题三:动态追加数据后图表不刷新

直接修改 List<FinancialPoint> 里的元素不会触发重绘,必须重新赋值 _series.Values = new List<FinancialPoint>(_data),或者把 Values 改为 ObservableCollection<FinancialPoint>,后者在 Add/Remove 时自动通知图表更新。


📊 性能参考数据

测试环境:Windows 11 / i7-12700H / .NET 10.0 / Release 模式

场景蜡烛数量渲染帧率内存占用
单系列静态100 根稳定 60fps~42MB
双系列静态各 100 根稳定 60fps~55MB
单系列动态追加滚动 48 根稳定 60fps~46MB
单系列大数据量5000 根~18fps~95MB

蜡烛数量超过 1000 根时建议做数据降采样,或限制可视区域的 X 轴范围,只渲染当前窗口内的蜡烛。


💬 技术讨论

蜡烛图在工业场景里其实还有很多有意思的变体用法。比如把"开盘/收盘"映射为"班次开始时的设备状态值 / 班次结束时的状态值",这样每根蜡烛就代表一个班次的完整数据摘要,管理层一眼就能看出哪个班次的工况最稳定。

你在项目里有没有用过蜡烛图或者类似的多维数据展示方案?或者你觉得工业数据还有哪些可视化需求是折线图和柱状图都满足不了的?欢迎在评论区聊聊。


🎯 总结

本文围绕 LiveCharts 2 的 CandlesticksSeries 在工业数据分析场景中的应用,梳理了三个渐进式方案:

  • 方案一:单系列基础蜡烛图,解决"折线图丢失波动过程"的核心问题,适合设备参数的周期性分析
  • 方案二:双系列双 Y 轴叠加,通过 ScalesYAt 实现不同量纲参数的联动对比,适合多参数关联分析
  • 方案三:动态追加 + RectangularSection 告警区域,适合实时监控场景的工程落地

蜡烛图的价值在于强迫你在数据采集阶段就记录完整的统计摘要,而不是用均值掩盖过程。这个思路本身就是一种更严谨的工业数据治理实践,值得在项目的数据层设计阶段就纳入考量。


#C# #WinForms #LiveCharts2 #工业数据可视化 #蜡烛图 #数据分析

相关信息

我用夸克网盘给你分享了「AppLiveChart17.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /e3d33Yf8yd:/ 链接:https://pan.quark.cn/s/78634fe7aa66 提取码:E9Fy

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!