在工厂数字化项目里,生产排程可视化是一个绕不开的需求。产线经理想知道每台设备今天排了几个工单、什么时候空出来;调度员需要直观地看到工序之间的时间冲突;质检人员要对照计划核查实际完工节点。
问题在于——大多数开发者面对"甘特图"这三个字,第一反应是去找第三方报表控件,结果发现收费、集成复杂、样式难以定制,或者直接用 Excel 糊弄了事。
实际上,用 WinForms + LiveCharts 2,完全可以在一个下午搭出一个可交互、可动态刷新、样式可控的生产排程甘特图。本文会带你从零走完整个过程:环境搭建 → 数据建模 → 基础甘特渲染 → 动态刷新与交互增强,每一步都有可直接运行的代码。
读完本文,你将掌握:
甘特图本质上是一种时间轴 × 资源轴的二维矩形分布图。X 轴是时间,Y 轴是资源(设备/人员/产线),每一个工单对应一个矩形块,矩形的左边界是开始时间,右边界是结束时间,高度表示资源占用。
听起来简单,但标准折线图、柱状图的数据结构完全对不上这个需求。常见误区有三个:
误区一:直接用 BarSeries 硬堆。普通柱状图的 X 轴是类别,没有"起始偏移"的概念,画出来的条形永远从零开始,根本不是甘特图。
误区二:用 DataGridView 模拟。用格子背景色来"画"甘特图,像素级对齐是噩梦,时间粒度一变整个布局就崩。
误区三:引入重型报表控件。DevExpress、Telerik 的甘特组件功能强大,但授权费用高、学习曲线陡,对于内部工具类项目来说性价比极低。
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 控件,或直接在代码中动态添加:
csharpusing LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.WinForms;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
csharpusing 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; } = "";
}
}

运行之后,你会看到一个按设备分行、按时间段展开的彩色横向条形图,不同状态的工单用不同颜色区分——这已经是一个完整可用的甘特图雏形了。
生产现场的数据是实时变化的,工单状态会从"待生产"切换为"生产中",甚至出现延期。静态甘特图只能看,动态甘特图才能用。
csharpprivate 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> { /* ... */ };
}
这是甘特图里非常实用的一个细节——一条红色竖线标记当前时刻,让调度员一眼看出哪些工单已经超时。
csharpprivate 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);
}
}
}

在每次刷新时调用 AddNowLine() 更新位置,时间线就会随着实际时间向右推移。
点击甘特条时弹出工单详情,是生产管理软件里非常常见的交互需求。LiveCharts 2 提供了 ChartPointPointerDown 事件,这个事件在正式版本中过时了,换成了DataPointerDown ,可以捕获点击的数据点。
csharpprivate 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()
};

实际项目中,MessageBox 可以替换为一个自定义的浮层面板或侧边抽屉,展示更丰富的工单信息,比如实际进度、操作员、BOM 清单等。
坑一:Y 轴标签与 MachineIndex 对不上。LiveCharts 2 的 Labels 数组是按索引顺序映射的,如果 MachineIndex 不是从 0 连续递增的,标签就会错位。解决方案是在数据预处理阶段重新归一化索引:
csharpvar 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,通过 Fill 的 Mapping 动态设置颜色。
坑三: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


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