面向刚接触 WPF、已经能写基本 XAML 和事件处理,准备学习“怎样把业务数据画成图表”的读者。
推荐环境:.NET 10 LTS + WPF。随附示例为了适配本机缓存依赖,固定使用 .NET Framework 4.6.2 与 x64,ScottPlot 代码本身同样适用于新版本 WPF。
最后更新:2026-10-23

目录

  1. ScottPlot图表库的引入
  2. 数据序列与Y轴
  3. 基于时间的X轴标签
  4. 轴标签的其他格式与预警线

写在前面

这份笔记解决什么问题

第一次用 WPF 做图表,常见的困难不是“不会写窗口”,而是下面这些:

1
2
3
4
5
6
图表控件放进去了,但一片空白
数据点明明有,曲线却不出现
时间直接塞给 X 轴,刻度变成一串大数字
纵轴每次刷新都自动缩放,读数没有可比性
中文标题和图例变成方框
预警线被当成一条假数据塞进数组

这一篇只围绕四个步骤展开:

1
2
3
4
先引入控件:分清 WpfPlot 与 Plot
再添加序列:理解 X、Y 数组怎样对应
然后处理时间:DateTime 先转 OADate
最后完善显示:轴格式、单位、预警线和刷新

四步知识地图

图 1:本篇的知识地图。每个步骤都能单独运行,也能组合成一张工业趋势图。

版本说明

ScottPlot 5 与 ScottPlot 4 的 API 差异很大。本文所有示例都按 ScottPlot 5.x 编写,并固定使用本机已缓存的 5.1.58:

1
<PackageReference Include="ScottPlot.WPF" Version="5.1.58" />

这样做的原因不是声称 5.1.58 是“2026 年最新版”,而是让文章、截图和示例代码有一致、可复现的基准。以后升级到新的 5.x 版本时,先看官方迁移说明,再修改项目文件。

ScottPlot 4 常见写法是:

1
WpfPlot.Plot.AddScatter(xs, ys);

ScottPlot 5 改为:

1
WpfPlot.Plot.Add.Scatter(xs, ys);

只差一个点,却会让旧教程里的代码完全无法编译。读到这里,先记住当前项目使用的是 Add.Scatter。

推荐学习顺序

  1. 先运行随附示例,依次点四个页签,只看每页显示什么。
  2. 第 1 章只做一张单曲线图,确认控件能正常出现。
  3. 第 2 章添加第二条曲线,并固定 Y 轴范围。
  4. 第 3 章把横轴换成时间,观察 ToOADate() 前后的变化。
  5. 第 4 章修改刻度文字,再加一条 80 MPa 的预警线。
  6. 最后故意制造一次“有坐标轴没有曲线”,按排错图定位原因。

随附示例位置

1
C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfScottPlotDemo

运行:

1
2
3
4
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfScottPlotDemo"
dotnet restore --source "$env:USERPROFILE\.nuget\packages" --ignore-failed-sources
dotnet build --no-restore
dotnet run --no-build

如果本机可以直接访问 NuGet,也可以只执行:

1
2
dotnet restore
dotnet run

Visual Studio 中:

  1. 打开 WpfScottPlotDemo.csproj。
  2. 确认启动项目是 WpfScottPlotDemo。
  3. 平台选择 x64。
  4. 按 F5。
  5. 依次点击四个页签。

创建自己的跟做项目

先创建一个普通 WPF 项目:

1
2
dotnet new wpf -n WpfScottPlotPractice -o .\WpfScottPlotPractice
cd .\WpfScottPlotPractice

打开 WpfScottPlotPractice.csproj,先按下面的方式设置。这里使用 net462 和 x64,是为了避开“旧版 OpenTK 依赖已在本机缓存、新版依赖未缓存”的环境差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net462</TargetFramework>
<LangVersion>latest</LangVersion>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="ScottPlot.WPF" Version="5.1.58" />
</ItemGroup>

</Project>

如果本机能正常联网还原 OpenTK 4.9.4 与 OpenTK.GLWpfControl 4.3.3,也可以把目标框架改成现代项目常用的:

1
<TargetFramework>net9.0-windows</TargetFramework>

两种目标框架下,本文的 ScottPlot 调用方式不变。不要一边用 ScottPlot 4 的 AddScatter,一边照抄本文的 Add.Scatter。

跟做完成后的结构

1
2
3
4
5
6
7
8
WpfScottPlotPractice
├─ App.xaml
├─ App.xaml.cs
├─ ChartSamples.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
└─ Models
└─ AppOptions.cs

本文使用 WpfScottPlotDemo 作为示例命名空间。你自己练习时,可以把命名空间换成项目名,但 XAML 中的 x:Class、代码隐藏文件和命名空间必须一致。

遇到问题时先看这张图

ScottPlot 常见问题排查

图 2:控件空白、曲线空白、中文方框、修改后不刷新,原因分别在不同层。


1. ScottPlot图表库的引入

本节目标

完成本章后,你应该能:

  • 在 WPF 项目中正确引入 ScottPlot.WPF。
  • 在 XAML 中放置 WpfPlot 控件。
  • 用两个等长数组画出第一张折线图。
  • 说清 WpfPlot 与 Plot 的区别。
  • 知道修改图表后为什么需要 Refresh()。

先理解职责分工

ScottPlot 最容易被误解的地方,是把 WpfPlot 当成“装数据的控件”。实际上它更像一个显示窗口:

1
2
3
4
5
6
7
8
9
10
WPF 窗口
│ 放置
▼
WpfPlot 控件 ── 显示、接收鼠标、请求重绘
│ 暴露
▼
Plot 模型 ── 保存序列、坐标轴、图例、预警线
│ 交给
▼
SkiaSharp ── 最终生成像素

WpfPlot 与 Plot 的职责分工

图 3:写代码时操作 Plot,显示时由 WpfPlot 承载。

记住这句话:

1
数据在 Plot 里,显示在 WpfPlot 上,刷新靠 Refresh。

第一步:引入命名空间

打开 MainWindow.xaml,在 Window 的开始标签中加入:

1
xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"

完整的窗口开始标签可以先写成:

1
2
3
4
5
6
7
8
9
<Window x:Class="WpfScottPlotDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"
Title="ScottPlot 5 图表实验台"
Width="1380"
Height="900"
FontFamily="Microsoft YaHei UI">
</Window>

这里的 ScottPlot 是 XAML 前缀,不要求与类名完全相同。写成 sp 也可以,只要后面使用同一个前缀。

第二步:在 XAML 放置控件

在页面中放一个 WpfPlot,先给它一个名字:

1
2
3
<Grid Margin="24">
<ScottPlot:WpfPlot x:Name="BasicPlot" />
</Grid>

x:Name="BasicPlot" 会在代码隐藏文件中生成同名字段,后面通过 BasicPlot.Plot 操作图表模型。

如果父容器没有可用宽高,控件可能显示成一条线或完全看不见。初学阶段先放进 Grid 的星号行或列:

1
2
3
4
5
6
7
8
9
10
11
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<TextBlock Text="压力趋势" />
<ScottPlot:WpfPlot x:Name="BasicPlot"
Grid.Row="1"
Margin="0,12,0,0" />
</Grid>

第三步:准备等长的 X、Y 数组

新建 ChartSamples.cs。先只写一组数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System.Linq;

namespace WpfScottPlotDemo;

public static class ChartSamples
{
public static double[] CreateXs()
{
return Enumerable.Range(0, 10)
.Select(index => (double)index)
.ToArray();
}

public static double[] CreateYs()
{
return new[]
{
24.0, 31.0, 38.0, 42.0, 47.0,
55.0, 61.0, 68.0, 76.0, 84.0
};
}
}

Xs 与 Ys 都是 double[],并且长度都是 10。下标相同的两个值组成一个坐标点:

1
(0, 24)  (1, 31)  (2, 38)  ...  (9, 84)

不要使用“少一个也没关系”的想法。ScottPlot 添加散点序列时,两个数组必须一一对应;长度不一致时应先在业务代码里修正,而不是让图表去猜。

第一张图的五步

图 4:准备数据、添加序列、设置样式、固定坐标、刷新。

第四步:添加第一条序列

在 MainWindow.xaml.cs 中写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Windows;
using ScottPlot;

namespace WpfScottPlotDemo;

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += OnWindowLoaded;
}

private void OnWindowLoaded(
object sender,
RoutedEventArgs e)
{
var xs = ChartSamples.CreateXs();
var ys = ChartSamples.CreateYs();

var plot = BasicPlot.Plot;
plot.Clear();

var series = plot.Add.Scatter(xs, ys);
series.Color = Color.FromHex("#2456A6");
series.LineWidth = 2.5f;
series.MarkerSize = 8;
series.LegendText = "压力";

plot.Axes.Bottom.Label.Text = "采样序号";
plot.Axes.Left.Label.Text = "压力 / MPa";
plot.Axes.SetLimits(0, 9, 0, 100);
plot.ShowLegend(Alignment.UpperLeft);

BasicPlot.Refresh();
}
}

这段代码可以分成五件事:

  1. BasicPlot.Plot:取得图表模型。
  2. plot.Clear():清掉上一次练习留下的内容。
  3. plot.Add.Scatter(...):用 X、Y 数组添加一条折线散点序列。
  4. Axes:给横纵轴写标题,并固定显示范围。
  5. BasicPlot.Refresh():请求 WPF 控件重新绘制。

第五步:认识 Series 返回值

plot.Add.Scatter 返回一个 Scatter 对象。常用设置如下:

属性 作用 示例
Color 线条和点的主颜色 Color.FromHex("#2456A6")
LineWidth 线宽 2.5f
MarkerSize 数据点标记大小 8
LegendText 图例文字 "压力"
LinePattern 实线、虚线等线型 LinePattern.Dashed

初学时不要一次把所有属性都改掉。先确认曲线出现,再逐项修改样式,才能知道是哪一个设置造成的变化。

第六步:处理中文字体

ScottPlot 的默认字体不一定包含中文字形。轴标题、图例中出现方框时,先设置字体:

1
plot.Font.Set("Microsoft YaHei UI");

建议把它放进一个公共方法,避免每一页都重复:

1
2
3
4
5
6
7
8
9
public static class ChartSamples
{
public const string ChartFont = "Microsoft YaHei UI";

public static void ApplyChineseFont(Plot plot)
{
plot.Font.Set(ChartFont);
}
}

调用顺序放在 Clear() 之后、添加序列之前:

1
2
3
var plot = BasicPlot.Plot;
plot.Clear();
ChartSamples.ApplyChineseFont(plot);

如果系统没有安装微软雅黑,可以换成 "SimHei" 或你机器上确实存在的中文字体。字体名称写错时,图形不一定报错,但文字可能回退成默认字体。

运行结果

运行后应看到一张从约 24 MPa 上升到 84 MPa 的蓝色折线图,左轴固定为 0 到 100,底部轴显示采样序号。

第一张 ScottPlot 图表

图 5:随附示例第一页。图表、中文轴标题和图例都能正常显示。

跟做检查点

完成本章后逐项确认:

  • 项目引用中出现 ScottPlot.WPF。
  • XAML 能识别 ScottPlot:WpfPlot。
  • Xs.Length 与 Ys.Length 相等。
  • 代码中调用的是 plot.Add.Scatter,不是 AddScatter。
  • 修改模型后调用了 BasicPlot.Refresh()。
  • 轴标题和图例中的中文不是方框。
  • Y 轴范围固定为 0 - 100,而不是随数据自动变化。

常见错误

错误 1:提示找不到 ​​WpfPlot​

先检查 XAML 是否加入了:

1
xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"

再检查项目文件是否真的引用了 ScottPlot.WPF。命名空间正确、包没安装,仍然无法解析控件。

错误 2:图表区域是空白

按下面的顺序排查:

1
2
3
4
5
WpfPlot 是否放在有实际宽高的容器中?
是否调用了 plot.Add.Scatter?
xs 和 ys 是否至少有一个点?
是否调用了 Refresh?
数据范围是否全部落在可见范围之外?

错误 3:曲线出现了,但中文是方框

这是字体问题,不是数据问题。调用:

1
plot.Font.Set("Microsoft YaHei UI");

错误 4:程序启动时报 ​​DllNotFoundException: libSkiaSharp​

ScottPlot 的绘图依赖 SkiaSharp 原生库。检查输出目录中是否存在 libSkiaSharp.dll,并确认进程位数与原生库一致。本文示例固定 win-x64,如果改用 Any CPU 后出现问题,先把运行环境明确为 x64。

小练习

把 Y 数据改成你自己熟悉的一组数值,例如一周七天的产量:

1
2
var xs = new double[] { 1, 2, 3, 4, 5, 6, 7 };
var ys = new double[] { 42, 48, 45, 53, 61, 58, 66 };

然后完成三个小改动:

  1. 把 X 轴标题改为“星期”。
  2. 把 Y 轴标题改为“产量 / 件”。
  3. 把 MarkerSize 改为 0,观察折线是否仍然清楚。

本章小结

本章真正需要记住的不是某一行样式代码,而是这个调用链:

1
准备数据 → Add.Scatter → 设置 Axes → Refresh

后面的多序列、时间轴和预警线,都是在这个链路上继续增加内容。


2. 数据序列与Y轴

本节目标

完成本章后,你应该能:

  • 在同一个 Plot 中添加多条数据序列。
  • 用图例区分不同业务含义。
  • 固定 Y 轴范围,避免刷新时坐标轴跳动。
  • 判断两条数据是否适合画在同一个 Y 轴上。

一个 Plot 可以有多条序列

Plot 内部维护一个 PlottableList。每调用一次 Add.Scatter,列表中就多一个序列对象:

1
2
3
4
5
Plot
├─ Scatter:进口压力
├─ Scatter:出口压力
├─ HorizontalLine:预警线
└─ Legend:图例

多条曲线共享同一个坐标系,所以它们的 X、Y 单位必须一致。进口压力和出口压力都可以用 MPa,因此可以共轴;温度与压力如果直接共用一个 Y 轴,即使线画出来了,读数也会误导人。

多序列与固定 Y 轴

图 6:两条压力序列共用一个纵轴,纵轴范围固定为 0 到 100 MPa。

第一步:准备两组数据

在 ChartSamples.cs 中加入第二组 Y 值:

1
2
3
4
5
6
7
8
public static double[] CreateSecondaryYs()
{
return new[]
{
18.0, 25.0, 30.0, 35.0, 41.0,
46.0, 52.0, 57.0, 63.0, 70.0
};
}

仍然复用同一组 X 值。这样每个下标对应同一时刻或同一采样序号下的两个读数。

第二步:添加两条序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void BuildSeriesChart()
{
var xs = ChartSamples.CreateXs();
var primaryYs = ChartSamples.CreateYs();
var secondaryYs = ChartSamples.CreateSecondaryYs();

var plot = SeriesPlot.Plot;
plot.Clear();
ChartSamples.ApplyChineseFont(plot);

var primary = plot.Add.Scatter(xs, primaryYs);
primary.Color = Color.FromHex("#2456A6");
primary.LineWidth = 2.5f;
primary.MarkerSize = 6;
primary.LegendText = "进口压力";

var secondary = plot.Add.Scatter(xs, secondaryYs);
secondary.Color = Color.FromHex("#D97706");
secondary.LineWidth = 2.5f;
secondary.MarkerSize = 6;
secondary.LegendText = "出口压力";

plot.Axes.Bottom.Label.Text = "采样序号";
plot.Axes.Left.Label.Text = "压力 / MPa";
plot.Axes.SetLimits(0, 9, 0, 100);
plot.ShowLegend(Alignment.UpperLeft);

SeriesPlot.Refresh();
}

注意每个 Scatter 都有自己的 LegendText。如果两条线都不写图例文字,用户只能靠颜色猜哪条是进口、哪条是出口,这在工业界面中不合格。

第三步:理解固定 Y 轴

下面这行同时设置 X、Y 范围:

1
plot.Axes.SetLimits(0, 9, 0, 100);

也可以分开设置:

1
2
plot.Axes.SetLimitsX(0, 9);
plot.Axes.SetLimitsY(0, 100);

固定 Y 轴有三个直接好处:

  • 曲线更新时纵轴不跳,眼睛容易比较变化幅度。
  • 同一设备不同批次的截图可以直接并排看。
  • 预警线始终处在固定高度,不会因为自动缩放而看起来“时高时低”。

固定范围不是永远不调整。设备量程、单位或工艺阈值发生变化时,应当由业务规则明确更新范围,而不是让图表悄悄自动缩放。

第四步:更新数据并刷新

初学时可以先替换序列的数据源,再刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void ReplaceSeriesData(
Plot plot,
Scatter oldSeries,
double[] xs,
double[] newYs)
{
var replacement = plot.Add.Scatter(xs, newYs);
replacement.Color = oldSeries.Color;
replacement.LineWidth = oldSeries.LineWidth;
replacement.MarkerSize = oldSeries.MarkerSize;
replacement.LegendText = oldSeries.LegendText;

plot.Remove(oldSeries);
plot.Axes.SetLimits(0, 9, 0, 100);
plot.ShowLegend(Alignment.UpperLeft);
}

真实实时曲线通常不会频繁删除并重建整个序列,而是更新数据源或追加点。这里先保留最直观的“替换 + 刷新”,等理解清楚后再优化。无论采用哪种方式,修改完成后都需要通知控件重绘。

运行结果

第二页应显示蓝色进口压力与橙色出口压力两条曲线,两条曲线共享 0 到 100 MPa 的纵轴。

双序列与固定 Y 轴

图 7:随附示例第二页。图例明确区分两条序列,纵轴保持固定。

跟做检查点

  • 两条 Scatter 都添加到了同一个 Plot。
  • 两组 Y 数据长度都与 X 相同。
  • 两条序列都设置了 LegendText。
  • SetLimitsY(0, 100) 没有被后面的自动缩放覆盖。
  • 点击“切换一组数据”后,纵轴仍然是 0 到 100。
  • 点击“恢复初始数据”后,图例没有重复堆叠。

常见错误

错误 1:第二条线覆盖了第一条

如果两次添加数据时都调用了 plot.Clear(),第一次添加的内容会被清掉。一个页面只应在构建开始时清一次,然后依次添加所有序列。

错误 2:图例中出现两个相同的“压力”

LegendText 是显示名称,不是变量名。给两条线分别写成“进口压力”“出口压力”,而不是都保留默认或同名文字。

错误 3:固定范围后又调用自动缩放

如果后续代码调用 plot.Axes.AutoScale(),它会重新计算范围。固定范围之后,不要在同一刷新流程里又要求自动缩放。

错误 4:不同单位硬塞进一个 Y 轴

温度 ℃ 与压力 MPa 没有可比性。要么统一单位,要么使用额外坐标轴,并在界面中明确说明每条线对应哪一侧的轴。

小练习

把出口压力整体加上 5 MPa:

1
2
3
var shifted = ChartSamples.CreateSecondaryYs()
.Select(value => value + 5)
.ToArray();

观察两条线的间距变化,再回答两个问题:

  1. 纵轴范围为什么仍然应该保持 0 到 100?
  2. 如果所有值都超过 100,界面应该提示什么,而不是悄悄把范围改成 0 到 120?

本章小结

多序列图的核心不是“多画几条线”,而是“让多条线在同一个可解释的坐标系里比较”。固定 Y 轴和图例文字,都是为了让比较有意义。


3. 基于时间的X轴标签

本节目标

完成本章后,你应该能:

  • 把 DateTime 转成 ScottPlot 能使用的 X 坐标。
  • 使用 DateTimeTicksBottom() 创建时间底部轴。
  • 通过 DateTimeAutomatic.LabelFormatter 修改时间标签。
  • 区分“坐标值”和“显示文字”。

为什么不能直接把 DateTime 传给 Scatter

ScottPlot 的坐标计算使用 double。DateTime 是结构体,不能直接作为 X 数组元素交给 Add.Scatter。

常见做法是先转成 OLE Automation Date,也就是 OADate:

1
2
3
4
5
6
7
8
var start = new DateTime(2026, 10, 23, 8, 0, 0);
var times = Enumerable.Range(0, 13)
.Select(index => start.AddMinutes(index * 10))
.ToArray();

var xs = times
.Select(time => time.ToOADate())
.ToArray();

ToOADate() 把日期时间变成一个连续的 double,这样 ScottPlot 就能像处理普通数值一样处理时间间隔。

时间坐标的三次转换

图 8:业务时间是 DateTime,坐标是 OADate,显示文字由刻度生成器还原。

第一步:准备时间与压力

1
2
3
4
5
6
7
8
9
10
var start = new DateTime(2026, 10, 23, 8, 0, 0);
var times = Enumerable.Range(0, 13)
.Select(index => start.AddMinutes(index * 10))
.ToArray();

var ys = new[]
{
38.0, 41.0, 44.0, 47.0, 51.0, 56.0, 62.0,
69.0, 75.0, 82.0, 86.0, 83.0, 79.0
};

这里每 10 分钟一个点,共 13 个点。时间数组和压力数组长度必须相同。

第二步:转换 X 坐标

1
2
3
var xs = times
.Select(time => time.ToOADate())
.ToArray();

转换后,xs 仍然是一组 double,但它代表的是时间。不要在这时用 Enumerable.Range 生成 0、1、2,否则横轴会变成序号,而不是真实时间。

第三步:换成时间底部轴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var plot = TimePlot.Plot;
plot.Clear();
ChartSamples.ApplyChineseFont(plot);

var series = plot.Add.Scatter(xs, ys);
series.Color = Color.FromHex("#176B4D");
series.LineWidth = 2.5f;
series.MarkerSize = 7;
series.LegendText = "压力";

plot.Axes.DateTimeTicksBottom();
plot.Axes.Bottom.Label.Text = "采样时间";
plot.Axes.Left.Label.Text = "压力 / MPa";
plot.Axes.SetLimitsY(0, 100);

TimePlot.Refresh();

DateTimeTicksBottom() 会移除原来的底部轴,创建一个日期时间轴,并把它加回图表。调用后,plot.Axes.Bottom.TickGenerator 应当是 DateTimeAutomatic。

第四步:修改时间标签格式

默认时间标签可能很长,也可能根据缩放级别自动变化。如果要固定显示“月-日 时:分”,先取出刻度生成器:

1
2
3
4
5
6
if (plot.Axes.Bottom.TickGenerator
is DateTimeAutomatic ticks)
{
ticks.LabelFormatter = date =>
date.ToString("MM-dd HH:mm");
}

这里委托的参数已经是 DateTime,不需要再调用 DateTime.FromOADate。不同版本的 API 可能对参数类型做过调整,所以写代码前先看 IDE 提示;本文按 5.1.58 的实际签名为准。

同一天内的数据,也可以只显示时分:

1
2
ticks.LabelFormatter = date =>
date.ToString("HH:mm");

跨天数据如果只显示 HH:mm,用户无法区分第二天和第一天的同一时刻。跨天时至少保留日期:

1
2
ticks.LabelFormatter = date =>
date.ToString("MM-dd HH:mm");

第五步:观察时间轴

运行第三页,底部刻度应该显示类似:

1
2
3
4
10-23 08:00
10-23 08:10
10-23 08:20
...

而不是一串普通整数或很长的 OADate 数字。

时间 X 轴运行效果

图 9:随附示例第三页。底部轴已经按时间显示,而不是显示 OADate 原值。

跟做检查点

  • X 数组是通过 ToOADate() 生成的。
  • 时间数组与 Y 数组长度相等。
  • 调用了 DateTimeTicksBottom()。
  • 底部刻度生成器是 DateTimeAutomatic。
  • 切换格式后,曲线形状没有变化,只有标签文字变化。
  • 跨天数据没有只显示时分。

常见错误

错误 1:X 轴显示 40000 多的大数字

这说明传给 Scatter 的确实是 OADate,但底部轴仍然是普通数值轴。补上:

1
plot.Axes.DateTimeTicksBottom();

错误 2:时间标签全是同一个时刻

检查 times 是否真的在递增。可以在调试器中查看第一个和最后一个时间:

1
2
Console.WriteLine(times.First());
Console.WriteLine(times.Last());

错误 3:直接修改 ​​xs​​ ** 的格式字符串**

xs 是 double[],没有“时间格式”。时间格式属于刻度生成器的显示逻辑,不属于坐标数组本身。

错误 4:刷新后格式又被重置

如果每次刷新都重新调用 DateTimeTicksBottom(),之前设置的自定义格式可能被新的生成器覆盖。格式设置应放在轴创建之后,并避免在每次数据刷新时重复重建整个轴。

小练习

把采样间隔从 10 分钟改成 30 分钟:

1
2
3
var times = Enumerable.Range(0, 13)
.Select(index => start.AddMinutes(index * 30))
.ToArray();

再把格式改成:

1
2
ticks.LabelFormatter = date =>
date.ToString("HH:mm");

观察:在数据跨度变长后,刻度是否仍能清楚表达时间间隔?

本章小结

时间轴要分三层理解:

1
2
3
DateTime:业务时间
OADate:ScottPlot 的 double 坐标
LabelFormatter:最终显示给用户的文字

只改最后一层,不会改变原始时间数据;忘记第二层,时间就无法进入图表。


4. 轴标签的其他格式与预警线

本节目标

完成本章后,你应该能:

  • 使用 NumericAutomatic.LabelFormatter 修改数值刻度文字。
  • 给刻度补上单位,而不是把单位藏在说明文字里。
  • 用 HorizontalLine 添加一条独立预警线。
  • 区分“数据序列”和“图表标注”。
  • 完成一张带格式、单位和预警线的工业趋势图。

刻度文字是显示层

原始 Y 值可能是 80、85、90。用户看到的文字却可以是:

1
2
3
80.0 MPa
85.0 MPa
90.0 MPa

数值没有变,变的是 LabelFormatter 生成的字符串。

刻度格式与单位

图 10:原始数值、带单位显示和自定义逻辑之间的关系。

第一步:修改数值轴格式

1
2
3
4
5
6
if (plot.Axes.Left.TickGenerator
is NumericAutomatic ticks)
{
ticks.LabelFormatter = value =>
$"{value:0.0} MPa";
}

这里保留一位小数,并在后面补上 MPa。若只想显示整数:

1
2
ticks.LabelFormatter = value =>
$"{value:0} MPa";

不要为了“看起来专业”给所有轴都加很多小数。刻度精度应该和业务测量精度一致。

第二步:修改 X 轴格式

示例中的 X 轴是运行小时数:

1
2
3
4
5
6
if (plot.Axes.Bottom.TickGenerator
is NumericAutomatic ticks)
{
ticks.LabelFormatter = value =>
$"{value:0} h";
}

如果要切换回无单位显示:

1
2
ticks.LabelFormatter = value =>
value.ToString("0");

轴的标题和刻度单位可以分工:

1
2
轴标题:运行时间
刻度:0 h、2 h、4 h

也可以把单位只放在轴标题中:

1
2
轴标题:运行时间 / h
刻度:0、2、4

同一张图里选择一种写法并保持一致,不要一半写单位、一半不写。

第三步:添加预警线

预警线不是采样数据。它表达的是“超过这个值就进入警告状态”的业务规则:

1
2
3
4
5
var alarmLine = plot.Add.HorizontalLine(80);
alarmLine.Color = Color.FromHex("#B42318");
alarmLine.LineWidth = 2;
alarmLine.LinePattern = LinePattern.Dashed;
alarmLine.LegendText = "预警线 80 MPa";

HorizontalLine 是 Plottables 命名空间下的独立标注对象。它不会被追加进原始 ys 数组,也不会改变压力数据本身。

预警线是标注

图 11:预警线固定在 80 MPa,原始曲线仍然按自己的采样值变化。

第四步:把数据、格式和预警线组合起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private void BuildAlarmChart()
{
var hours = Enumerable.Range(0, 10)
.Select(index => index * 2.0)
.ToArray();

var pressure = new[]
{
24.0, 31.0, 38.0, 42.0, 47.0,
55.0, 61.0, 68.0, 76.0, 84.0
};

var plot = AlarmPlot.Plot;
plot.Clear();
ChartSamples.ApplyChineseFont(plot);

var series = plot.Add.Scatter(hours, pressure);
series.Color = Color.FromHex("#2456A6");
series.LineWidth = 2.5f;
series.MarkerSize = 7;
series.LegendText = "压力";

var alarmLine = plot.Add.HorizontalLine(80);
alarmLine.Color = Color.FromHex("#B42318");
alarmLine.LineWidth = 2;
alarmLine.LinePattern = LinePattern.Dashed;
alarmLine.LegendText = "预警线 80 MPa";

plot.Axes.Bottom.Label.Text = "运行时间 / 小时";
plot.Axes.Left.Label.Text = "压力 / MPa";
plot.Axes.SetLimits(0, 18, 0, 100);

if (plot.Axes.Bottom.TickGenerator
is NumericAutomatic bottomTicks)
{
bottomTicks.LabelFormatter = value =>
$"{value:0} h";
}

if (plot.Axes.Left.TickGenerator
is NumericAutomatic leftTicks)
{
leftTicks.LabelFormatter = value =>
$"{value:0.0} MPa";
}

plot.ShowLegend(Alignment.UpperLeft);
AlarmPlot.Refresh();
}

这段代码的顺序值得注意:

1
清空 → 字体 → 数据序列 → 预警线 → 轴标题 → 范围 → 标签格式 → 图例 → 刷新

顺序不是死规定,但先创建对象、再修改属性、最后刷新,读起来最清楚。

第五步:让预警线有业务含义

图表中的线只是视觉提示,真正的报警状态仍应由业务规则判断:

1
2
3
4
5
6
public const double AlarmPressure = 80;

public static bool IsAlarm(double pressure)
{
return pressure >= AlarmPressure;
}

不要把“线画在哪里”和“是否报警”写成两套互不相干的魔法数字。阈值应该来自同一份规则,界面和数据判断才能保持一致。

运行结果

第四页应显示蓝色压力曲线、红色虚线预警线和带单位的刻度标签。

轴格式与预警线运行效果

图 12:随附示例第四页。X 轴显示小时,Y 轴保留一位小数和 MPa,80 MPa 预警线为红色虚线。

跟做检查点

  • Y 轴刻度显示一位小数和 MPa。
  • X 轴刻度显示小时。
  • 预警线固定在 80 MPa。
  • 预警线是虚线且颜色明显,但不抢过主曲线。
  • 图例能区分“压力”和“预警线 80 MPa”。
  • 修改格式后调用了 Refresh()。
  • 原始压力数组中没有手动插入 80 这个“假数据点”。

常见错误

错误 1:把预警线放进 ys 数组

错误思路:

1
var ys = new[] { 24, 31, 38, 80, 55, 61 };

这会把 80 当成某个时刻的真实采样值,曲线也会被强行拉到一个不存在的数据点。正确做法是使用 HorizontalLine。

错误 2:格式字符串没有生效

先确认刻度生成器确实是 NumericAutomatic。如果之前调用过 DateTimeTicksBottom(),底部轴生成器已经是时间类型,不能再按数值轴强转。

错误 3:轴标题写“压力”,刻度又写“MPa”,但单位不一致

例如轴标题写 kPa,刻度格式化却写 MPa。单位必须在数据、规则、轴标题和刻度之间保持一致。

错误 4:修改后没有刷新

1
AlarmPlot.Refresh();

漏掉这一行时,模型已经改了,屏幕却还停留在上一次绘制结果。

小练习

把预警线阈值从 80 改成 75,并同步修改:

  1. 业务判断方法中的阈值。
  2. 预警线的 Y 值。
  3. 图例文字。
  4. 左侧轴刻度的显示格式。

然后增加一条 90 MPa 的红色点划线,观察两条预警线是否能清楚区分。

本章小结

一张可信的工业趋势图,至少要说清四件事:

1
2
3
4
画的是什么数据:序列与图例
横轴是什么:时间或运行时长
纵轴是什么单位:轴标题与刻度
什么情况需要警告:独立预警线与业务规则

格式只负责“怎么显示”,数据序列负责“真实值”,预警线负责“业务阈值”。三者分开,图表才容易维护。


完整跟做清单

项目文件

确认 WpfScottPlotDemo.csproj 至少包含:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net462</TargetFramework>
<LangVersion>latest</LangVersion>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="ScottPlot.WPF" Version="5.1.58" />
</ItemGroup>

</Project>

四个页面的检查顺序

页签 应看到什么 关键 API
1 引入与第一张图 一条蓝色压力曲线 Add.Scatter、Refresh
2 数据序列与 Y 轴 两条曲线共用固定纵轴 多次 Add.Scatter、SetLimitsY
3 时间 X 轴 横轴显示日期时间 ToOADate、DateTimeTicksBottom
4 格式与预警线 带单位刻度、红色虚线 NumericAutomatic.LabelFormatter、HorizontalLine

自测命令

项目内包含一个不打开界面的自测入口:

1
.\bin\Debug\net462\win-x64\WpfScottPlotDemo.exe --self-test

自测结果会写入:

1
bin\Debug\net462\win-x64\self-test-results.txt

本次验证结果为 5/5 通过,覆盖:

  • 第一张图已创建。
  • X 与 Y 长度一致。
  • 两条序列已创建。
  • 时间轴生成器为 DateTimeAutomatic。
  • 80 MPa 预警线存在且数量为一条。

截图命令

随附示例支持从程序内部截图,避免依赖当前桌面窗口是否被遮挡:

1
2
3
.\bin\Debug\net462\win-x64\WpfScottPlotDemo.exe `
--screenshot "C:\temp\scottplot-tab1.png" `
--screenshot-tab 1

把 --screenshot-tab 改为 2、3、4,可以分别抓取另外三页。这个入口只用于验证与生成教程截图,不参与正常业务逻辑。

最后自检

  • 四个页签都能打开。
  • 每页图表都有真实曲线,不是空白。
  • 中文轴标题、图例、状态文字都能显示。
  • 第二页纵轴固定为 0 到 100。
  • 第三页时间标签不是 OADate 原值。
  • 第四页预警线位于 80 MPa。
  • 修改图表后都调用了 Refresh()。
  • 项目能够 dotnet build 且无错误。

写在最后

ScottPlot 的入门门槛不高,但真正写出“可信的图”需要把四层分开:

1
2
3
4
数据层:xs、ys、DateTime、业务阈值
模型层:Plot、Scatter、HorizontalLine、Axes
显示层:LabelFormatter、字体、颜色、图例
刷新层:WpfPlot.Refresh

只要每加一个功能,都能说清它属于哪一层,图表出现空白、标签不对或预警线位置错误时,就不会再靠猜。

下一步可以继续练习:

  1. 把定时采集的数据追加到已有序列,观察实时曲线的滚动效果。
  2. 使用 plot.Axes.Right 增加第二纵轴,比较不同单位的数据。
  3. 用业务规则计算预警状态,并在界面顶部同步显示报警文字。
  4. 把图表封装到用户控件中,让同一套趋势图复用到多个设备页面。

把这篇的四页示例完整跑通,再动手改数据、单位和阈值,你会比只看 API 列表更快建立自己的图表思维。