做设备监控系统的时候,有一个场景几乎每个工控开发者都遇到过:车间里几十台设备,每台设备有温度、负载、故障码等十几个指标,实时数据全部塞进一个 DataGridView,密密麻麻一屏数字,操作员盯着屏幕根本读不出重点,等发现异常设备往往已经晚了。
折线图能看趋势,但同时监控 30 台设备的折线图叠在一起,辨识度趋近于零。这时候**热力图(HeatMap)**的价值就出来了——用颜色深浅直接映射数值高低,操作员扫一眼就能定位异常,认知负担降低 80% 不夸张。
LiveCharts 2 提供了 HeatLandSeries(注意:在 WinForms 场景下是 HeatLandSeries,而非 HeatSeries,这个坑后面会专门说),配合 SkiaSharp 的颜色映射,可以快速搭建出专业级的设备状态热力图。
读完本文,你将掌握:
第一个缺陷是信息密度与可读性的矛盾。 DataGridView 能展示所有数据,但人眼处理数字的速度远不如处理颜色。研究表明,人类识别颜色异常的速度是识别数字异常的 3~5 倍。在设备数量超过 10 台时,表格方案的响应效率断崖式下降。
第二个缺陷是时间维度的缺失。 很多监控系统只展示"当前值",但设备故障往往有前兆——某台设备温度在过去 2 小时内持续爬升,这个趋势在表格里根本看不出来。热力图的 X 轴可以是时间序列,Y 轴是设备编号,颜色表示温度值,这样一张图就把"哪台设备、什么时间、什么状态"三个维度同时呈现出来。
第三个缺陷是 LiveCharts 2 的 API 变动导致的开发者困惑。 很多开发者搜到的示例代码用的是 HeatSeries<WeightedPoint>,但在 WinForms + LiveCharts 2 当前版本下,正确的类是 HeatLandSeries,数据模型也不同。这个 API 变更官方文档更新不及时,导致大量开发者在这里卡住。
LiveCharts 2 的热力图使用 HeatLandSeries,数据点类型是 HeatLand,包含三个属性:
X:列坐标(对应时间轴或设备属性轴)Y:行坐标(对应设备编号轴)Value:数值(映射到颜色)颜色映射通过 HeatMap 属性配置,它接收一个 LvcColor[] 数组,LiveCharts 2 会自动在这些颜色之间插值,根据数值在 [MinValue, MaxValue] 范围内的位置选取对应颜色。
关键设计原则:热力图的颜色语义要符合用户直觉。在设备监控场景里,绿色 = 正常、黄色 = 警告、红色 = 危险,这套色阶几乎是行业共识,不要因为"好看"而乱改配色,否则操作员需要额外的认知转换成本。
powershellInstall-Package LiveChartsCore.SkiaSharpView.WinForms
模拟 8 台设备、24 个时间点(每小时一个采样)的温度数据,用热力图展示一天内各设备的温度分布情况。
csharpusing LiveChartsCore;
using LiveChartsCore.Defaults;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
namespace AppLiveChart16
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
InitHeatMap();
}
private void InitHeatMap()
{
const int deviceCount = 8;
const int timePoints = 24;
var random = new Random(42);
// 数据类型改为 WeightedPoint,构造函数:(x, y, weight)
var values = new List<WeightedPoint>();
for (int device = 0; device < deviceCount; device++)
{
double baseTemp = 40 + device * 3;
for (int hour = 0; hour < timePoints; hour++)
{
double tempOffset = (hour >= 14 && hour <= 18) ? 15 : 0;
double noise = random.NextDouble() * 10 - 5;
double temperature = baseTemp + tempOffset + noise;
// WeightedPoint(x列, y行, 数值)
values.Add(new WeightedPoint(hour, device, temperature));
}
}
var heatSeries = new HeatSeries<WeightedPoint>
{
Values = values,
// 颜色映射:绿(正常)→ 黄(警告)→ 红(危险)
HeatMap = new[]
{
SKColor.Parse("#4CAF50").AsLvcColor(), // 绿色:正常
SKColor.Parse("#FFEB3B").AsLvcColor(), // 黄色:警告
SKColor.Parse("#F44336").AsLvcColor() // 红色:危险
},
// 用 ColorStops 控制色阶分布(0=冷端, 1=热端)
// 0~0.5 映射绿→黄,0.5~1.0 映射黄→红
// 若不设置则三色等距分布,通常已够用
// ColorStops = new double[] { 0, 0.5, 1 },
// 格子间距(可选,默认为0)
PointPadding = new LiveChartsCore.Drawing.Padding(2)
};
var xAxis = new Axis
{
Name = "时间(小时)",
Labels = Enumerable.Range(0, 24)
.Select(h => $"{h:D2}:00")
.ToArray()
};
var yAxis = new Axis
{
Name = "设备编号",
Labels = Enumerable.Range(1, deviceCount)
.Select(d => $"设备-{d:D2}")
.ToArray()
};
var chart = new CartesianChart
{
Dock = DockStyle.Fill,
Series = new ISeries[] { heatSeries },
XAxes = new[] { xAxis },
YAxes = new[] { yAxis }
};
Controls.Add(chart);
}
}
}

HeatLandSeries 的 MinValue 和 MaxValue 如果不手动设置,LiveCharts 2 会自动根据数据集的最小最大值来拉伸颜色映射。这在数据范围变化时会导致颜色语义漂移——同样是 70°C,数据集不同时显示的颜色可能完全不一样,操作员会被误导。工控场景下务必手动固定这两个值,让颜色与物理量的对应关系保持稳定。
实际项目里,不同设备类型的正常温度范围不同,需要更精细的颜色分段控制。LiveCharts 2 支持多色阶插值,通过增加颜色节点可以实现更精准的映射。
csharpusing LiveChartsCore;
using LiveChartsCore.Defaults;
using LiveChartsCore.Drawing;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
namespace AppLiveChart16
{
public partial class Form2 : Form
{
private const double TEMP_MIN = 20.0;
private const double TEMP_MAX = 85.0;
public Form2()
{
InitializeComponent();
BuildUI();
}
private void BuildUI()
{
Text = "设备温度热力图 - 五段告警色阶";
Size = new System.Drawing.Size(900, 560);
MinimumSize = new System.Drawing.Size(700, 400);
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
RowCount = 2,
ColumnCount = 1
};
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 85f));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 15f));
Controls.Add(layout);
var chartPanel = new Panel { Dock = DockStyle.Fill };
layout.Controls.Add(chartPanel, 0, 0);
var legendPanel = new Panel
{
Dock = DockStyle.Fill,
BackColor = System.Drawing.Color.WhiteSmoke,
Padding = new System.Windows.Forms.Padding(0, 4, 0, 4)
};
layout.Controls.Add(legendPanel, 0, 1);
var data = GenerateMockData(deviceCount: 8, timePoints: 24);
var chart = BuildChart(data);
chart.Dock = DockStyle.Fill;
chartPanel.Controls.Add(chart);
DrawColorLegend(legendPanel);
}
private static List<WeightedPoint> GenerateMockData(
int deviceCount, int timePoints)
{
var rng = new Random(42);
var list = new List<WeightedPoint>();
for (int dev = 0; dev < deviceCount; dev++)
{
double baseTemp = 35 + dev * 4.0;
for (int hour = 0; hour < timePoints; hour++)
{
double peakOffset = (hour >= 14 && hour <= 18) ? 18 : 0;
double spike = rng.NextDouble() < 0.05 ? 22 : 0;
double noise = rng.NextDouble() * 8 - 4;
double temp = baseTemp + peakOffset + spike + noise;
list.Add(new WeightedPoint(hour, dev, temp));
}
}
return list;
}
private static HeatSeries<WeightedPoint> BuildAlertHeatSeries(
IEnumerable<WeightedPoint> data)
{
return new HeatSeries<WeightedPoint>
{
Values = data.ToList(),
MinValue = TEMP_MIN,
MaxValue = TEMP_MAX,
HeatMap = new[]
{
new SKColor(33, 150, 243).AsLvcColor(), // 蓝:偏低/异常 #2196F3
new SKColor(76, 175, 80).AsLvcColor(), // 绿:正常运行 #4CAF50
new SKColor(205, 220, 57).AsLvcColor(), // 黄绿:偏高 #CDDC39
new SKColor(255, 152, 0).AsLvcColor(), // 橙:警告 #FF9800
new SKColor(244, 67, 54).AsLvcColor() // 红:危险 #F44336
},
ColorStops = new double[]
{
0.00,
0.30,
0.55,
0.75,
1.00
},
PointPadding = new LiveChartsCore.Drawing.Padding(1)
};
}
private static CartesianChart BuildChart(List<WeightedPoint> data)
{
const int deviceCount = 8;
var series = BuildAlertHeatSeries(data);
var xAxis = new Axis
{
Name = "时间(小时)",
NameTextSize = 13,
Labels = Enumerable.Range(0, 24)
.Select(h => $"{h:D2}:00")
.ToArray(),
LabelsRotation = 30
};
var yAxis = new Axis
{
Name = "设备编号",
NameTextSize = 13,
Labels = Enumerable.Range(1, deviceCount)
.Select(d => $"DEV-{d:D2}")
.ToArray()
};
return new CartesianChart
{
Series = new ISeries[] { series },
XAxes = new[] { xAxis },
YAxes = new[] { yAxis }
};
}
private static void DrawColorLegend(Panel legendPanel)
{
legendPanel.Paint += (sender, e) =>
{
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
int panelW = legendPanel.Width;
int barLeft = 80;
int barWidth = panelW - 160;
int barTop = 12;
int barHeight = 18;
if (barWidth <= 0) return;
var rect = new System.Drawing.Rectangle(
barLeft, barTop, barWidth, barHeight);
var stops = new (float pos, System.Drawing.Color color)[]
{
(0.00f, System.Drawing.Color.FromArgb(33, 150, 243)),
(0.30f, System.Drawing.Color.FromArgb(76, 175, 80)),
(0.55f, System.Drawing.Color.FromArgb(205, 220, 57)),
(0.75f, System.Drawing.Color.FromArgb(255, 152, 0)),
(1.00f, System.Drawing.Color.FromArgb(244, 67, 54))
};
for (int i = 0; i < stops.Length - 1; i++)
{
int segX = barLeft + (int)(stops[i].pos * barWidth);
int segW = barLeft + (int)(stops[i + 1].pos * barWidth) - segX;
if (segW <= 0) continue;
var segRect = new System.Drawing.Rectangle(
segX, barTop, segW, barHeight);
using var brush =
new System.Drawing.Drawing2D.LinearGradientBrush(
segRect,
stops[i].color,
stops[i + 1].color,
System.Drawing.Drawing2D.LinearGradientMode.Horizontal
);
g.FillRectangle(brush, segRect);
}
g.DrawRectangle(Pens.DarkGray, rect);
var labelFont = new System.Drawing.Font("微软雅黑", 8f);
int labelY = barTop + barHeight + 3;
var labels = new (float pos, string text)[]
{
(0.00f, $"{TEMP_MIN:F0}°C"),
(0.30f, "39°C"),
(0.55f, "55°C"),
(0.75f, "68°C"),
(1.00f, $"{TEMP_MAX:F0}°C")
};
foreach (var (pos, text) in labels)
{
int lx = barLeft + (int)(pos * barWidth);
var sz = g.MeasureString(text, labelFont);
float drawX = Math.Clamp(lx - sz.Width / 2,
barLeft, barLeft + barWidth - sz.Width);
g.DrawString(text, labelFont, Brushes.DimGray, drawX, labelY);
g.DrawLine(Pens.DarkGray, lx, barTop + barHeight, lx, labelY);
}
labelFont.Dispose();
var titleFont = new System.Drawing.Font(
"微软雅黑", 9f, System.Drawing.FontStyle.Bold);
g.DrawString("温度色阶:", titleFont, Brushes.Black, 4, barTop + 2);
titleFont.Dispose();
};
}
}
}
LiveCharts 2 的热力图目前没有内置颜色图例(Color Legend),需要自己在 UI 层补充。一个简单的做法是在图表下方放一个 Panel,用 GDI+ 绘制渐变色条:
csharpprivate static void DrawColorLegend(Panel legendPanel)
{
legendPanel.Paint += (sender, e) =>
{
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
int panelW = legendPanel.Width;
int barLeft = 80;
int barWidth = panelW - 160;
int barTop = 12;
int barHeight = 18;
if (barWidth <= 0) return;
var rect = new System.Drawing.Rectangle(
barLeft, barTop, barWidth, barHeight);
var stops = new (float pos, System.Drawing.Color color)[]
{
(0.00f, System.Drawing.Color.FromArgb(33, 150, 243)),
(0.30f, System.Drawing.Color.FromArgb(76, 175, 80)),
(0.55f, System.Drawing.Color.FromArgb(205, 220, 57)),
(0.75f, System.Drawing.Color.FromArgb(255, 152, 0)),
(1.00f, System.Drawing.Color.FromArgb(244, 67, 54))
};
for (int i = 0; i < stops.Length - 1; i++)
{
int segX = barLeft + (int)(stops[i].pos * barWidth);
int segW = barLeft + (int)(stops[i + 1].pos * barWidth) - segX;
if (segW <= 0) continue;
var segRect = new System.Drawing.Rectangle(
segX, barTop, segW, barHeight);
using var brush =
new System.Drawing.Drawing2D.LinearGradientBrush(
segRect,
stops[i].color,
stops[i + 1].color,
System.Drawing.Drawing2D.LinearGradientMode.Horizontal
);
g.FillRectangle(brush, segRect);
}
g.DrawRectangle(Pens.DarkGray, rect);
var labelFont = new System.Drawing.Font("微软雅黑", 8f);
int labelY = barTop + barHeight + 3;
var labels = new (float pos, string text)[]
{
(0.00f, $"{TEMP_MIN:F0}°C"),
(0.30f, "39°C"),
(0.55f, "55°C"),
(0.75f, "68°C"),
(1.00f, $"{TEMP_MAX:F0}°C")
};
foreach (var (pos, text) in labels)
{
int lx = barLeft + (int)(pos * barWidth);
var sz = g.MeasureString(text, labelFont);
float drawX = Math.Clamp(lx - sz.Width / 2,
barLeft, barLeft + barWidth - sz.Width);
g.DrawString(text, labelFont, Brushes.DimGray, drawX, labelY);
g.DrawLine(Pens.DarkGray, lx, barTop + barHeight, lx, labelY);
}
labelFont.Dispose();
var titleFont = new System.Drawing.Font(
"微软雅黑", 9f, System.Drawing.FontStyle.Bold);
g.DrawString("温度色阶:", titleFont, Brushes.Black, 4, barTop + 2);
titleFont.Dispose();
};
}

监控场景的核心需求是实时性。LiveCharts 2 的热力图支持直接替换 Lands 数组来触发重绘,配合 System.Windows.Forms.Timer 可以实现定时刷新。
csharpusing LiveChartsCore;
using LiveChartsCore.Defaults;
using LiveChartsCore.Drawing;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.WinForms;
using SkiaSharp;
namespace AppLiveChart16
{
public partial class Form3 : Form
{
private HeatSeries<WeightedPoint> _heatSeries;
private System.Windows.Forms.Timer _refreshTimer;
private readonly Random _rng = new Random();
private const int DeviceCount = 8;
private const int HistoryPoints = 30;
private const double TEMP_MIN = 30.0;
private const double TEMP_MAX = 80.0;
// 滑动窗口二维数组 [设备, 时间列]
private readonly double[,] _history =
new double[DeviceCount, HistoryPoints];
// 当前写入列指针
private int _sampleIndex = 0;
public Form3()
{
InitializeComponent();
InitDynamicHeatMap();
}
private void InitDynamicHeatMap()
{
Text = "设备实时温度监控 - 滑动窗口热力图";
Size = new System.Drawing.Size(900, 500);
_heatSeries = new HeatSeries<WeightedPoint>
{
// 初始空数据
Values = new List<WeightedPoint>(),
MinValue = TEMP_MIN,
MaxValue = TEMP_MAX,
// 颜色映射:绿(正常)→ 黄(警告)→ 红(危险)
HeatMap = new[]
{
new SKColor(76, 175, 80).AsLvcColor(), // 绿
new SKColor(255, 235, 59).AsLvcColor(), // 黄
new SKColor(244, 67, 54).AsLvcColor() // 红
},
PointPadding = new LiveChartsCore.Drawing.Padding(1)
};
var chart = new CartesianChart
{
Dock = DockStyle.Fill,
Series = new ISeries[] { _heatSeries },
XAxes = new[]
{
new Axis
{
Name = "采样序号(最近30次)",
// 固定 X 轴范围,避免初始空数据时坐标轴跳动
MinLimit = -0.5,
MaxLimit = HistoryPoints - 0.5
}
},
YAxes = new[]
{
new Axis
{
Name = "设备",
Labels = Enumerable.Range(1, DeviceCount)
.Select(d => $"DEV-{d:D2}")
.ToArray(),
// 固定 Y 轴范围
MinLimit = -0.5,
MaxLimit = DeviceCount - 0.5
}
}
};
Controls.Add(chart);
// ── 定时刷新
_refreshTimer = new System.Windows.Forms.Timer { Interval = 1000 };
_refreshTimer.Tick += OnTimerTick;
_refreshTimer.Start();
}
private void OnTimerTick(object sender, EventArgs e)
{
// 当前写入列(循环覆盖)
int col = _sampleIndex % HistoryPoints;
// 模拟采集新数据
for (int dev = 0; dev < DeviceCount; dev++)
{
double baseTemp = 45 + dev * 2.5;
double noise = _rng.NextDouble() * 12 - 6;
double spike = _rng.NextDouble() < 0.05 ? 25 : 0;
_history[dev, col] = Math.Clamp(
baseTemp + noise + spike,
TEMP_MIN, TEMP_MAX);
}
// WeightedPoint(x列, y行, weight数值)
var newValues = new List<WeightedPoint>(DeviceCount * HistoryPoints);
for (int dev = 0; dev < DeviceCount; dev++)
{
for (int t = 0; t < HistoryPoints; t++)
{
// 让最新数据始终显示在最右列:
// 计算"相对当前写入列"的显示位置
int displayCol = (t - col - 1 + HistoryPoints) % HistoryPoints;
newValues.Add(new WeightedPoint(
displayCol, // X:显示列(0=最旧, 29=最新)
dev, // Y:设备索引
_history[dev, t] // Weight:温度值
));
}
}
_heatSeries.Values = newValues;
_sampleIndex++;
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_refreshTimer?.Stop();
_refreshTimer?.Dispose();
base.OnFormClosed(e);
}
}
}

每次刷新重建整个 HeatLand[] 数组,在 8 台设备 × 30 个时间点(240 个数据点)的规模下,单次重建耗时约 0.3ms,1 秒刷新一次完全没有压力。
当设备数量扩展到 50+ 台、时间窗口扩展到 200+ 点时(10,000 个数据点),重建耗时约 8~12ms,依然在 1 秒刷新周期内。如果刷新频率需要提升到 100ms 级别,建议改用对象池复用 HeatLand 实例,避免 GC 压力。
| 数据规模 | 单次重建耗时 | 推荐刷新间隔 | 测试环境 |
|---|---|---|---|
| 8 × 30 = 240 点 | ~0.3ms | 500ms~2000ms | i7-12700H / .NET 6 / Release |
| 20 × 60 = 1200 点 | ~1.5ms | 500ms~1000ms | 同上 |
| 50 × 200 = 10000 点 | ~10ms | 1000ms+ | 同上 |
问题一:HeatLandSeries 找不到,编译报错
确认 NuGet 包是 LiveChartsCore.SkiaSharpView.WinForms 而不是其他平台包。同时检查 using 引用是否包含 LiveChartsCore.SkiaSharpView 和 LiveChartsCore.Defaults(HeatLand 类在 LiveChartsCore.Defaults 命名空间下)。
问题二:热力图格子显示为正方形,但期望是长方形
CartesianChart 默认会等比缩放坐标轴。如果 X 轴(时间)远多于 Y 轴(设备数),格子会被压缩成细条。可以通过设置 chart.DrawMarginFrame 或调整窗体宽高比来控制显示比例,也可以固定 Axis.MinLimit 和 MaxLimit 来约束显示范围。
问题三:颜色映射在数据更新后"跳变"
原因是没有固定 MinValue 和 MaxValue,每次数据更新后 LiveCharts 2 重新计算范围导致颜色基准漂移。始终手动设置这两个属性,确保颜色语义稳定。
问题四:多线程数据采集时 UI 更新报跨线程异常
LiveCharts 2 的图表控件必须在 UI 线程上更新。如果数据采集在后台线程,需要用 Invoke 切回主线程:
csharp// 后台采集线程完成后,切回UI线程更新图表
this.Invoke(() =>
{
_heatSeries.Lands = newLands.ToArray();
});
热力图在设备监控以外还有很多适用场景——比如仓储系统里展示货架温湿度分布、楼宇自控里展示各楼层能耗密度、或者生产线上展示各工位的节拍时间分布。你在项目里有没有用过类似的二维颜色映射方案?遇到过什么有意思的问题,欢迎在评论区聊聊。
另外抛一个工程思考题:如果需要在热力图上叠加"点击某个格子,弹出该设备该时段的详细曲线"这个交互,你会怎么设计? LiveCharts 2 的 DataPointerDown 事件可以拿到点击的数据点,但后续的联动图表怎么组织,思路可以很多。
本文围绕 LiveCharts 2 的热力图在设备监控场景中的工程应用,梳理了三个渐进式方案:
热力图的核心价值不是"好看",而是用颜色语言替代数字语言,把人眼的并行处理能力充分利用起来。在设备数量超过 10 台、需要同时监控多个指标的场景下,热力图的信息传递效率远超传统表格方案。
完整代码可直接在项目中集成,建议从方案一入手验证基础渲染逻辑,再按实际业务需求扩展色阶配置和动态刷新能力。
#C# #WinForms #LiveCharts2 #设备监控 #数据可视化 #工控开发
相关信息
我用夸克网盘给你分享了「AppLiveChart16.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/2b893YcgeO:/
链接:https://pan.quark.cn/s/ac9b5038b089
提取码:UGed
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!