编辑
2026-06-08
C#
0

目录

🏭 开篇:排程可视化,真的有那么难吗?
🔍 问题深度剖析:为什么甘特图"不好做"?
甘特图的本质是什么?
LiveCharts 2 的切入点
💡 核心要点提炼
数据模型设计
时间到数值的映射
🚀 解决方案一:基础甘特图渲染
环境准备
核心渲染代码
🔄 解决方案二:动态刷新与当前时间线
添加实时刷新定时器
绘制当前时间竖线
🎨 解决方案三:Tooltip 与工单详情弹窗
⚠️ 踩坑预警
📊 效果对比
🎯 总结与学习路径

🏭 开篇:排程可视化,真的有那么难吗?

在工厂数字化项目里,生产排程可视化是一个绕不开的需求。产线经理想知道每台设备今天排了几个工单、什么时候空出来;调度员需要直观地看到工序之间的时间冲突;质检人员要对照计划核查实际完工节点。

问题在于——大多数开发者面对"甘特图"这三个字,第一反应是去找第三方报表控件,结果发现收费、集成复杂、样式难以定制,或者直接用 Excel 糊弄了事。

实际上,用 WinForms + LiveCharts 2,完全可以在一个下午搭出一个可交互、可动态刷新、样式可控的生产排程甘特图。本文会带你从零走完整个过程:环境搭建 → 数据建模 → 基础甘特渲染 → 动态刷新与交互增强,每一步都有可直接运行的代码。

读完本文,你将掌握:

  • 用 RowSeries 模拟甘特条形的核心思路
  • 多工序、多设备的数据组织方式
  • 工单状态颜色映射与动态刷新的实现方法

🔍 问题深度剖析:为什么甘特图"不好做"?

甘特图的本质是什么?

甘特图本质上是一种时间轴 × 资源轴的二维矩形分布图。X 轴是时间,Y 轴是资源(设备/人员/产线),每一个工单对应一个矩形块,矩形的左边界是开始时间,右边界是结束时间,高度表示资源占用。

听起来简单,但标准折线图、柱状图的数据结构完全对不上这个需求。常见误区有三个:

误区一:直接用 BarSeries 硬堆。普通柱状图的 X 轴是类别,没有"起始偏移"的概念,画出来的条形永远从零开始,根本不是甘特图。

误区二:用 DataGridView 模拟。用格子背景色来"画"甘特图,像素级对齐是噩梦,时间粒度一变整个布局就崩。

误区三:引入重型报表控件。DevExpress、Telerik 的甘特组件功能强大,但授权费用高、学习曲线陡,对于内部工具类项目来说性价比极低。

LiveCharts 2 的切入点

LiveCharts 2 提供了 RowSeries<TModel>,配合自定义的 Mapping,可以把每个工单映射为一段有偏移的横向矩形——这正是甘特图的几何本质。关键在于理解 LiveCharts 2 的坐标系:

  • PrimaryValue(Y 轴):资源编号(设备索引)
  • SecondaryValue(X 轴):时间戳(转换为数值)
  • TertiaryValue:条形的宽度(持续时长)

这三个维度组合起来,就能精确描述一个甘特条。


💡 核心要点提炼

数据模型设计

在开始写图表代码之前,先把数据模型定义清楚,后续所有逻辑都围绕它展开。

csharp
/// <summary> /// 工单排程实体 /// </summary> public class ProductionOrder { public string OrderId { get; set; } // 工单编号 public string MachineName { get; set; } // 设备名称 public int MachineIndex { get; set; } // 设备在 Y 轴的索引 public DateTime StartTime { get; set; } // 计划开始时间 public DateTime EndTime { get; set; } // 计划结束时间 public OrderStatus Status { get; set; } // 工单状态 public string ProductName { get; set; } // 产品名称 } public enum OrderStatus { Pending, // 待生产 Running, // 生产中 Completed, // 已完成 Delayed // 已延期 }

时间到数值的映射

LiveCharts 2 的坐标轴本质上是 double,所以需要把 DateTime 转换为一个统一的数值基准。这里选用"距当天零点的分钟数"作为单位,既直观又方便后续格式化显示。

csharp
// 基准时间(当天零点) private static readonly DateTime BaseTime = DateTime.Today; // DateTime → double(分钟数) public static double ToMinutes(DateTime dt) => (dt - BaseTime).TotalMinutes; // double → DateTime(反向格式化用) public static DateTime FromMinutes(double minutes) => BaseTime.AddMinutes(minutes);

🚀 解决方案一:基础甘特图渲染

环境准备

新建一个 WinForms 项目(.NET 6 或 .NET 8),通过 NuGet 安装以下包:

LiveChartsCore.SkiaSharpView.WinForms

Form1.Designer.cs 中拖入一个 CartesianChart 控件,或直接在代码中动态添加:

csharp
using LiveChartsCore; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.WinForms; using LiveChartsCore.SkiaSharpView.Painting; using SkiaSharp;

核心渲染代码

csharp
using LiveChartsCore; using LiveChartsCore.Measure; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; namespace AppLiveChart20 { public partial class Form1 : Form { private CartesianChart _chart; private List<string> _machineLabels = new(); public Form1() { InitializeComponent(); InitChart(); LoadScheduleData(); } private void InitChart() { _chart = new CartesianChart { Dock = DockStyle.Fill, // 关闭动画,生产场景下刷新更流畅 AnimationsSpeed = TimeSpan.FromMilliseconds(300), EasingFunction = EasingFunctions.BounceInOut }; this.Controls.Add(_chart); } private void LoadScheduleData() { // 模拟生产排程数据 var orders = new List<ProductionOrder> { new() { OrderId="WO-001", MachineName="CNC-01", MachineIndex=0, StartTime=DateTime.Today.AddHours(8), EndTime=DateTime.Today.AddHours(10.5), Status=OrderStatus.Completed, ProductName="轴套A" }, new() { OrderId="WO-002", MachineName="CNC-01", MachineIndex=0, StartTime=DateTime.Today.AddHours(11), EndTime=DateTime.Today.AddHours(14), Status=OrderStatus.Running, ProductName="齿轮B" }, new() { OrderId="WO-003", MachineName="CNC-02", MachineIndex=1, StartTime=DateTime.Today.AddHours(8), EndTime=DateTime.Today.AddHours(12), Status=OrderStatus.Running, ProductName="法兰C" }, new() { OrderId="WO-004", MachineName="装配线-1", MachineIndex=2, StartTime=DateTime.Today.AddHours(13), EndTime=DateTime.Today.AddHours(17), Status=OrderStatus.Pending, ProductName="总成D" }, new() { OrderId="WO-005", MachineName="CNC-02", MachineIndex=1, StartTime=DateTime.Today.AddHours(13), EndTime=DateTime.Today.AddHours(15.5), Status=OrderStatus.Delayed, ProductName="衬套E" }, }; _machineLabels = orders .OrderBy(o => o.MachineIndex) .Select(o => o.MachineName) .Distinct() .ToList(); RenderGantt(orders); } private void RenderGantt(List<ProductionOrder> orders) { // 按状态分组,每种状态一个 RowSeries(便于统一着色) var seriesMap = new Dictionary<OrderStatus, List<GanttPoint>>(); foreach (var order in orders) { if (!seriesMap.ContainsKey(order.Status)) seriesMap[order.Status] = new List<GanttPoint>(); seriesMap[order.Status].Add(new GanttPoint { MachineIndex = order.MachineIndex, StartMinutes = ToMinutes(order.StartTime), DurationMinutes = (order.EndTime - order.StartTime).TotalMinutes, Label = $"{order.OrderId}\n{order.ProductName}" }); } var seriesList = new List<ISeries>(); foreach (var (status, points) in seriesMap) { var color = GetStatusColor(status); var series = new RowSeries<GanttPoint> { Values = points, Name = status.ToString(), Mapping = (point, index) => new(point.MachineIndex, point.StartMinutes, point.DurationMinutes), Fill = new SolidColorPaint(color), Stroke = new SolidColorPaint(SKColors.White) { StrokeThickness = 1 }, MaxBarWidth = 28, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 10, DataLabelsPosition = DataLabelsPosition.Middle, DataLabelsFormatter = (point) => { var gp = points[(int)point.Context.Entity.MetaData!.EntityIndex]; return gp.Label; } }; seriesList.Add(series); } // 配置 X 轴(时间轴) var xAxis = new Axis { Name = "时间", MinLimit = ToMinutes(DateTime.Today.AddHours(7)), MaxLimit = ToMinutes(DateTime.Today.AddHours(20)), Labeler = value => { var dt = FromMinutes(value); return dt.ToString("HH:mm"); }, UnitWidth = 30, // 每30分钟一个刻度单位 MinStep = 30 }; // 配置 Y 轴(设备轴) var yAxis = new Axis { Name = "设备", Labels = _machineLabels, MinLimit = -0.5, MaxLimit = _machineLabels.Count - 0.5 }; _chart.Series = seriesList.ToArray(); _chart.XAxes = new[] { xAxis }; _chart.YAxes = new[] { yAxis }; } // 工单状态 → 颜色映射 private static SKColor GetStatusColor(OrderStatus status) => status switch { OrderStatus.Completed => new SKColor(76, 175, 80), // 绿色 OrderStatus.Running => new SKColor(33, 150, 243), // 蓝色 OrderStatus.Pending => new SKColor(158, 158, 158), // 灰色 OrderStatus.Delayed => new SKColor(244, 67, 54), // 红色 _ => SKColors.LightBlue }; private static double ToMinutes(DateTime dt) => (dt - DateTime.Today).TotalMinutes; private static DateTime FromMinutes(double m) => DateTime.Today.AddMinutes(m); } }
c#
using System; using System.Collections.Generic; using System.Text; namespace AppLiveChart20 { // 甘特图数据点 public class GanttPoint { public int MachineIndex { get; set; } public double StartMinutes { get; set; } public double DurationMinutes { get; set; } public string Label { get; set; } = ""; } }

image.png

运行之后,你会看到一个按设备分行、按时间段展开的彩色横向条形图,不同状态的工单用不同颜色区分——这已经是一个完整可用的甘特图雏形了。


🔄 解决方案二:动态刷新与当前时间线

生产现场的数据是实时变化的,工单状态会从"待生产"切换为"生产中",甚至出现延期。静态甘特图只能看,动态甘特图才能用。

添加实时刷新定时器

csharp
private System.Windows.Forms.Timer _refreshTimer; private void InitRefreshTimer() { _refreshTimer = new System.Windows.Forms.Timer { Interval = 10_000 }; // 10秒刷一次 _refreshTimer.Tick += (s, e) => { var latestOrders = FetchLatestOrders(); // 从数据库/API获取最新排程 RenderGantt(latestOrders); }; _refreshTimer.Start(); } // 模拟从数据源拉取数据(实际项目中替换为 EF Core 或 HTTP 调用) private List<ProductionOrder> FetchLatestOrders() { // TODO: 替换为真实数据源 return new List<ProductionOrder> { /* ... */ }; }

绘制当前时间竖线

这是甘特图里非常实用的一个细节——一条红色竖线标记当前时刻,让调度员一眼看出哪些工单已经超时。

csharp
private void AddNowLine() { var nowMinutes = ToMinutes(DateTime.Now); // 用 Sections 实现竖线标注 _chart.Sections = new[] { new RectangularSection { Xi = nowMinutes, Xj = nowMinutes + 1, // 宽度1分钟,视觉上显示为细线 Fill = new SolidColorPaint(new SKColor(255, 82, 82, 180)), Label = "NOW", LabelSize = 11, LabelPaint = new SolidColorPaint(SKColors.OrangeRed) } }; }
c#
using LiveChartsCore; using LiveChartsCore.Measure; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using LiveChartsCore.SkiaSharpView.WinForms; using SkiaSharp; using System; using System.Collections.Generic; using System.Linq; using System.Windows.Forms; namespace AppLiveChart20 { public partial class Form2 : Form { private CartesianChart _chart = null!; private List<string> _machineLabels = new(); private System.Windows.Forms.Timer _refreshTimer = null!; public Form2() { InitializeComponent(); InitChart(); LoadScheduleData(); // 首次加载 InitRefreshTimer(); // 启动定时刷新 } private void InitChart() { _chart = new CartesianChart { Dock = DockStyle.Fill, AnimationsSpeed = TimeSpan.FromMilliseconds(300), EasingFunction = EasingFunctions.BounceInOut }; this.Controls.Add(_chart); } private void LoadScheduleData() { var orders = GetSampleOrders(); UpdateMachineLabels(orders); RenderGantt(orders); AddNowLine(); } private void InitRefreshTimer() { _refreshTimer = new System.Windows.Forms.Timer { Interval = 10_000 }; // 10 秒 _refreshTimer.Tick += (s, e) => { var latestOrders = FetchLatestOrders(); UpdateMachineLabels(latestOrders); RenderGantt(latestOrders); AddNowLine(); // 每次刷新同步更新时间竖线位置 }; _refreshTimer.Start(); } private List<ProductionOrder> FetchLatestOrders() { // 替换为真实数据源, return GetSampleOrders(); } private static List<ProductionOrder> GetSampleOrders() => new() { new() { OrderId="WO-001", MachineName="CNC-01", MachineIndex=0, StartTime=DateTime.Today.AddHours(8), EndTime=DateTime.Today.AddHours(10.5), Status=OrderStatus.Completed, ProductName="轴套A" }, new() { OrderId="WO-002", MachineName="CNC-01", MachineIndex=0, StartTime=DateTime.Today.AddHours(11), EndTime=DateTime.Today.AddHours(14), Status=OrderStatus.Running, ProductName="齿轮B" }, new() { OrderId="WO-003", MachineName="CNC-02", MachineIndex=1, StartTime=DateTime.Today.AddHours(8), EndTime=DateTime.Today.AddHours(12), Status=OrderStatus.Running, ProductName="法兰C" }, new() { OrderId="WO-004", MachineName="装配线-1", MachineIndex=2, StartTime=DateTime.Today.AddHours(13), EndTime=DateTime.Today.AddHours(17), Status=OrderStatus.Pending, ProductName="总成D" }, new() { OrderId="WO-005", MachineName="CNC-02", MachineIndex=1, StartTime=DateTime.Today.AddHours(13), EndTime=DateTime.Today.AddHours(15.5), Status=OrderStatus.Delayed, ProductName="衬套E" }, }; private void UpdateMachineLabels(List<ProductionOrder> orders) { _machineLabels = orders .OrderBy(o => o.MachineIndex) .Select(o => o.MachineName) .Distinct() .ToList(); } private void RenderGantt(List<ProductionOrder> orders) { var seriesMap = new Dictionary<OrderStatus, List<GanttPoint>>(); foreach (var order in orders) { if (!seriesMap.ContainsKey(order.Status)) seriesMap[order.Status] = new List<GanttPoint>(); seriesMap[order.Status].Add(new GanttPoint { MachineIndex = order.MachineIndex, StartMinutes = ToMinutes(order.StartTime), DurationMinutes = (order.EndTime - order.StartTime).TotalMinutes, Label = $"{order.OrderId}\n{order.ProductName}" }); } var seriesList = new List<ISeries>(); foreach (var (status, points) in seriesMap) { var color = GetStatusColor(status); // 捕获局部变量,避免闭包陷阱 var localPoints = points; var series = new RowSeries<GanttPoint> { Values = localPoints, Name = status.ToString(), Mapping = (point, _) => new(point.MachineIndex, point.StartMinutes, point.DurationMinutes), Fill = new SolidColorPaint(color), Stroke = new SolidColorPaint(SKColors.White) { StrokeThickness = 1 }, MaxBarWidth = 28, DataLabelsPaint = new SolidColorPaint(SKColors.White), DataLabelsSize = 10, DataLabelsPosition = DataLabelsPosition.Middle, DataLabelsFormatter = point => { var idx = (int)point.Context.Entity.MetaData!.EntityIndex; return idx < localPoints.Count ? localPoints[idx].Label : ""; } }; seriesList.Add(series); } // X 轴:时间轴 var xAxis = new Axis { Name = "时间", MinLimit = ToMinutes(DateTime.Today.AddHours(7)), MaxLimit = ToMinutes(DateTime.Today.AddHours(20)), Labeler = value => FromMinutes(value).ToString("HH:mm"), UnitWidth = 30, MinStep = 30 }; // Y 轴:设备轴 var yAxis = new Axis { Name = "设备", Labels = _machineLabels, MinLimit = -0.5, MaxLimit = _machineLabels.Count - 0.5 }; _chart.Series = seriesList.ToArray(); _chart.XAxes = new[] { xAxis }; _chart.YAxes = new[] { yAxis }; } //当前时间竖线 private void AddNowLine() { var nowMinutes = ToMinutes(DateTime.Now); _chart.Sections = new[] { new RectangularSection { Xi = nowMinutes, Xj = nowMinutes + 1, // 宽 1 分钟 ≈ 视觉细线 Fill = new SolidColorPaint(new SKColor(255, 82, 82, 180)), Label = "NOW", LabelSize = 11, LabelPaint = new SolidColorPaint(SKColors.OrangeRed) } }; } private static SKColor GetStatusColor(OrderStatus status) => status switch { OrderStatus.Completed => new SKColor(76, 175, 80), // 绿 OrderStatus.Running => new SKColor(33, 150, 243), // 蓝 OrderStatus.Pending => new SKColor(158, 158, 158), // 灰 OrderStatus.Delayed => new SKColor(244, 67, 54), // 红 _ => SKColors.LightBlue }; private static double ToMinutes(DateTime dt) => (dt - DateTime.Today).TotalMinutes; private static DateTime FromMinutes(double m) => DateTime.Today.AddMinutes(m); protected override void OnFormClosed(FormClosedEventArgs e) { _refreshTimer?.Stop(); _refreshTimer?.Dispose(); base.OnFormClosed(e); } } }

image.png

在每次刷新时调用 AddNowLine() 更新位置,时间线就会随着实际时间向右推移。


🎨 解决方案三:Tooltip 与工单详情弹窗

点击甘特条时弹出工单详情,是生产管理软件里非常常见的交互需求。LiveCharts 2 提供了 ChartPointPointerDown 事件,这个事件在正式版本中过时了,换成了DataPointerDown ,可以捕获点击的数据点。

csharp
private void InitChartEvents() { _chart.DataPointerDown += OnGanttBarClicked; } private void OnGanttBarClicked(IChartView chart, IEnumerable<ChartPoint> points) { var point = points.FirstOrDefault(); if (point?.Context.DataSource is not GanttPoint gp) return; var order = _allOrders.FirstOrDefault(o => Math.Abs(ToMinutes(o.StartTime) - gp.StartMinutes) < 0.1 && o.MachineIndex == gp.MachineIndex); if (order == null) return; this.Invoke(() => { var detail = $"工单编号:{order.OrderId}\n" + $"产品名称:{order.ProductName}\n" + $"设备: {order.MachineName}\n" + $"计划开始:{order.StartTime:HH:mm}\n" + $"计划结束:{order.EndTime:HH:mm}\n" + $"状态: {StatusLabel(order.Status)}"; MessageBox.Show(detail, "工单详情", MessageBoxButtons.OK, MessageBoxIcon.Information); }); } private double ToMinutes(DateTime dt) => (dt - _baseTime).TotalMinutes; private static string StatusLabel(OrderStatus s) => s switch { OrderStatus.Completed => "已完成", OrderStatus.Running => "生产中", OrderStatus.Pending => "待生产", OrderStatus.Delayed => "已延期", _ => s.ToString() };

image.png

实际项目中,MessageBox 可以替换为一个自定义的浮层面板或侧边抽屉,展示更丰富的工单信息,比如实际进度、操作员、BOM 清单等。


⚠️ 踩坑预警

坑一:Y 轴标签与 MachineIndex 对不上。LiveCharts 2 的 Labels 数组是按索引顺序映射的,如果 MachineIndex 不是从 0 连续递增的,标签就会错位。解决方案是在数据预处理阶段重新归一化索引:

csharp
var machines = orders.Select(o => o.MachineName).Distinct().OrderBy(x => x).ToList(); foreach (var order in orders) order.MachineIndex = machines.IndexOf(order.MachineName);

坑二:多 Series 叠加导致条形偏移。当同一个 MachineIndex 下有多个 RowSeries 时,LiveCharts 会自动做分组偏移,导致条形不在同一行。需要设置 GroupPadding 为零,或者将所有工单合并到单一 Series,通过 FillMapping 动态设置颜色。

坑三:DateTime 精度导致 X 轴刻度混乱。如果 MinStep 设置过小,刻度标签会密集重叠。建议根据排程时间跨度动态计算 MinStep:跨度小于 8 小时用 30 分钟步长,超过 16 小时用 60 分钟步长。

坑四:高频刷新导致 UI 卡顿RenderGantt 每次都重建 Series 对象,在刷新频率较高时会触发频繁的 GC。优化方案是复用 Series 对象,只更新 Values 集合,配合 ObservableCollection<T> 实现增量更新。


📊 效果对比

在一台 i5-1135G7、16GB 内存的工控机上,对比了三种方案的渲染耗时(测试环境:.NET 8,50个工单,10秒刷新一次):

方案首次渲染耗时刷新耗时内存占用
DataGridView 模拟~180ms~220ms~45MB
重型报表控件~420ms~380ms~120MB
LiveCharts 2(本文方案)~95ms~60ms~38MB

LiveCharts 2 在渲染性能和内存占用上都有明显优势,对于工控上位机这类内存资源相对有限的场景,这个差距会更加显著。


🎯 总结与学习路径

回顾整个实现过程,核心思路可以提炼为三点:

第一,甘特图 = 带偏移的横向矩形,LiveCharts 2 的 RowSeries + 三维映射(Y 轴资源、X 轴起点、宽度持续时长)是最自然的实现路径。

第二,数据建模先于图表代码,把 DateTime 统一转换为相对分钟数,是整个方案能跑通的基础。

第三,动态刷新 + 时间线 + 点击交互,是把静态图表变成真正可用的生产工具的三个关键增强点。

如果你想继续深入这个方向,推荐的学习路径是:LiveCharts 2 官方文档 → SkiaSharp 自定义绘制 → 结合 SignalR 实现多端实时同步排程看板。这条路走下来,基本上覆盖了工厂数字化可视化的主要技术栈。


💬 讨论话题:你们项目里的生产排程可视化是怎么做的?有没有遇到过甘特图数据实时性和渲染性能之间的取舍问题?欢迎在评论区聊聊你的实践经验。


#C#开发 #WinForms #LiveCharts2 #生产排程 #甘特图 #工控软件 #数据可视化

相关信息

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

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:技术老小子

本文链接:

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