返回

WPF LiveCharts教程:折线图从特定时间点精准绘制

windows

好的,这是您要求的技术博客文章:


WPF C# LiveCharts:让折线图从特定时间点开始绘制

搞 C# WPF 开发,用数据库数据显示图表挺常见的。你可能已经成功把数据库查询和基本的图表显示做出来了,但遇到了一个有点“拧巴”的需求:怎么让图表上的折线,不是从最左边或者最右边开始画,而是根据实际数据存在的时间点,在图表的中间某个位置“凭空”出现,又在某个时间点“突然”结束?就像下面这张图(理想效果图)展示的那样?

期望的图表效果

你遇到的问题可能是,图表库默认会把所有数据系列(比如代表不同传感器的线)从图表的开头一直画到结尾,没数据的地方可能会自动补零或者直接连线,结果就成了下面这种(实际遇到的问题图):

不符合预期的图表效果

尤其是想做成“实时”更新的图表,数据点是根据时间动态加进来的,如何精确控制每条线只在它有数据的那个时间段内显示,这确实是个值得琢磨的问题。本文就来聊聊在 WPF C# 环境下,怎么用 LiveCharts 这个库(或者类似思路的其他库)来实现这种效果。

问题在哪儿?

看下你提供的代码片段,问题主要出在 vUpdateSeries 这个方法里:

private void vUpdateSeries(List<SensingDataPoint> dataPoints)
{
    // ... (省略部分代码)
    foreach (var point in dataPoints)
    {
        // ...
        TimeLabels.Add(point.Time.ToString("HH:mm:ss")); // 关键点1:所有点共享一个时间轴标签
        switch (point.ID)
        {
            case 1:
                ID1SensorValues.Add(point.Value);
                // 关键点2:即使 ID 不是 2, 3, 4, 5,也给它们加了个 0
                ID2SensorValues.Add(0);
                ID3SensorValues.Add(0);
                ID4SensorValues.Add(0);
                ID5SensorValues.Add(0);
                break;
            case 2:
                ID1SensorValues.Add(0);
                ID2SensorValues.Add(point.Value);
                ID3SensorValues.Add(0);
                ID4SensorValues.Add(0);
                ID5SensorValues.Add(0);
                break;
            // ... (其他 case 类似)
            default:
                break;
        }
        // ...
    }
}

根本原因分析:

  1. 共享时间轴标签和“补零”策略 :你的代码逻辑是,只要数据库里查到一个时间点 point.Time 有任何一个传感器 (point.ID) 的数据,你就把这个时间点 point.Time 对应的字符串加到 TimeLabels 里,作为 X 轴的一个刻度。然后,在 switch 语句里,只给对应 ID 的 SensorValues 列表添加实际的 point.Value,而给所有其他 ID 的列表都添加了一个 0
  2. 强制连续性 :图表库拿到这些数据后,它看到的是在每一个 TimeLabels 对应的位置上,每个 IDxSensorValues 都有一个值(要么是真实值,要么是你手动加的 0)。所以,它自然会把这些点连起来,从第一个时间标签一直画到最后一个,造成了所有线条都横跨整个图表的现象。即使某个传感器在某个时间段根本没有数据,也被你强制用 0 值给“填”上了。

要实现线条只在有数据时绘制,就不能用这种“补零”的笨办法。我们需要让图表库知道:某个传感器在某个时间点 没有 数据,而不是数据值为零。

解决方案:使用时间坐标轴和可空数据点

现代的图表库,比如 LiveCharts,通常都支持更灵活的数据表示方式。核心思路是:

  1. 使用基于实际时间值的 X 轴 :不要用字符串 TimeLabels 作 X 轴,而是直接用 DateTime 对象或者表示时间戳的数值(比如 Ticks)作为 X 轴的数据类型。这样 X 轴就能准确反映数据点的时间。
  2. 为每个传感器维护独立的数据序列 :每个传感器(ID 1 到 5)应该有自己的数据点集合,而不是混在一起处理后再拆分并补零。
  3. 利用 nulldouble.NaN 表示数据缺失 :当某个传感器在特定时间点没有数据时,不要添加 0,理想情况下应该是不添加任何点,或者如果数据结构要求必须有对应项,则添加一个特殊值,如图表库能识别的 nulldouble.NaN (Not a Number)。LiveCharts 通常会自动处理这种情况,不在 nullNaN 的位置绘制点或连线,从而形成“断开”的效果。

推荐使用 LiveCharts (Geared)

对于实时数据更新,LiveCharts 的 Geared 包 (LiveCharts.Geared) 提供了更好的性能。咱们以 LiveCharts 为例,看看具体怎么做。

步骤一:改造数据模型和 ViewModel

首先,我们需要调整 ViewModel 中的数据结构。不再使用 List<string> TimeLabels 和多个 List<double> IDxSensorValues。改为为每个传感器(每个 ID)创建一个专门的数据序列,这个序列存储的是包含时间和值的数据点。LiveCharts 推荐使用 ChartValues<T>ObservableCollection<T> 来存储数据,这样数据变动时能自动通知 UI 更新。

这里我们用 DateTimePoint 作为数据点类型,它正好包含时间和值。

// 安装 LiveCharts.Wpf 和 LiveCharts.Geared (通过 NuGet)
// using LiveCharts;
// using LiveCharts.Defaults; // 为了 DateTimePoint
// using LiveCharts.Wpf;
// using LiveCharts.Geared; // 为了 GChartValues

public class MainViewModel : INotifyPropertyChanged // 假设你的 ViewModel 实现了 INotifyPropertyChanged
{
    // 每个传感器对应一个 ChartValues 集合,存储 (时间, 值) 数据点
    public GChartValues<DateTimePoint> ID1SensorValues { get; set; }
    public GChartValues<DateTimePoint> ID2SensorValues { get; set; }
    public GChartValues<DateTimePoint> ID3SensorValues { get; set; }
    public GChartValues<DateTimePoint> ID4SensorValues { get; set; }
    public GChartValues<DateTimePoint> ID5SensorValues { get; set; }

    // X轴的格式化器,将 DateTime Ticks 转为可读的时间字符串
    public Func<double, string> DateTimeFormatter { get; set; }

    // (可选) X轴的范围控制,用于实现实时滚动效果
    public double AxisMin { get; set; }
    public double AxisMax { get; set; }

    // 构造函数
    public MainViewModel()
    {
        ID1SensorValues = new GChartValues<DateTimePoint>();
        ID2SensorValues = new GChartValues<DateTimePoint>();
        ID3SensorValues = new GChartValues<DateTimePoint>();
        ID4SensorValues = new GChartValues<DateTimePoint>();
        ID5SensorValues = new GChartValues<DateTimePoint>();

        // 设置 X 轴标签格式
        DateTimeFormatter = value => new DateTime((long)value).ToString("HH:mm:ss");

        // 初始化 X 轴范围 (例如,显示最近 60 秒的数据)
        AxisMax = DateTime.Now.Ticks;
        AxisMin = DateTime.Now.AddSeconds(-60).Ticks;

        // 启动数据更新任务 (示例)
        StartDataUpdates();
    }

    // --- 数据更新逻辑 ---
    private Timer _timer;
    private readonly object _lockObject = new object(); // 用于线程同步
    private bool _isUpdating = false; // 防止重入

    private void StartDataUpdates()
    {
        _timer = new Timer(UpdateChartDataCallback, null, TimeSpan.Zero, TimeSpan.FromSeconds(1)); // 每秒更新一次
    }

    private void UpdateChartDataCallback(object state)
    {
        // 防止更新任务重叠执行
        if (_isUpdating) return;
        lock (_lockObject)
        {
            if (_isUpdating) return;
            _isUpdating = true;
        }

        try
        {
            // 模拟从数据库获取最新数据点 (你的 dbHelper.GetSensingData 需要调整)
            // 注意:这里不再是获取固定的 _iCount 条,而是获取某个时间窗口内的新数据
            // 或者根据你的需求,仍然获取最新的 N 条,但在处理时要注意时间
            var newDataPoints = dbHelper.GetNewSensingDataSince(GetLastTimestamp()); // 假设有这样一个方法

            // 在后台线程处理数据,准备添加到图表
            ProcessNewData(newDataPoints);

            // (可选) 控制图表X轴范围,实现滚动效果
            UpdateAxisRange();
        }
        finally
        {
            lock (_lockObject)
            {
                _isUpdating = false;
            }
        }
    }

    private DateTime GetLastTimestamp()
    {
        // 获取当前所有系列中最新的时间戳,用于下次查询
        DateTime lastTime = DateTime.MinValue;
        var allSeries = new[] { ID1SensorValues, ID2SensorValues, ID3SensorValues, ID4SensorValues, ID5SensorValues };
        foreach(var series in allSeries)
        {
            if (series.Any())
            {
                var currentLast = new DateTime((long)series.Last().DateTime);
                if (currentLast > lastTime)
                {
                    lastTime = currentLast;
                }
            }
        }
        // 如果图表为空,可以返回一个合理的起始时间,比如现在往前一段时间
        return lastTime == DateTime.MinValue ? DateTime.Now.AddMinutes(-5) : lastTime;
    }

    private void ProcessNewData(List<SensingDataPoint> newDataPoints)
    {
        if (newDataPoints == null || !newDataPoints.Any()) return;

        // 按 ID 分组,方便添加到对应的 Series
        var groupedData = newDataPoints.GroupBy(p => p.ID);

        // 使用 Dispatcher 更新 UI 绑定的集合
        Application.Current.Dispatcher.Invoke(() =>
        {
            foreach (var group in groupedData)
            {
                GChartValues<DateTimePoint> targetSeries = null;
                switch (group.Key) // group.Key 就是传感器 ID
                {
                    case 1: targetSeries = ID1SensorValues; break;
                    case 2: targetSeries = ID2SensorValues; break;
                    case 3: targetSeries = ID3SensorValues; break;
                    case 4: targetSeries = ID4SensorValues; break;
                    case 5: targetSeries = ID5SensorValues; break;
                }

                if (targetSeries != null)
                {
                    foreach (var point in group.OrderBy(p => p.Time)) // 确保按时间顺序添加
                    {
                        // 核心:创建 DateTimePoint,X 是时间 Ticks,Y 是值
                        targetSeries.Add(new DateTimePoint(point.Time, point.Value));
                    }
                }
            }

            // (可选) 移除旧数据,保持图表点数可控
            RemoveOldDataPoints();
        });
    }

    private void UpdateAxisRange()
    {
        var now = DateTime.Now;
        var nowTicks = now.Ticks;
        var minTicks = now.AddSeconds(-60).Ticks; // 显示最近 60 秒

        // 更新绑定到 X 轴 MinValue 和 MaxValue 的属性
        // 需要在 ViewModel 中添加 AxisMin 和 AxisMax 属性,并实现 INotifyPropertyChanged
        AxisMin = minTicks;
        AxisMax = nowTicks;

        // 触发 PropertyChanged 事件
        OnPropertyChanged(nameof(AxisMin));
        OnPropertyChanged(nameof(AxisMax));
    }

     private void RemoveOldDataPoints()
    {
        // 定义一个保留时间窗口,比如只保留最近 5 分钟的数据点
        var retentionThreshold = DateTime.Now.AddMinutes(-5).Ticks;
        var allSeries = new[] { ID1SensorValues, ID2SensorValues, ID3SensorValues, ID4SensorValues, ID5SensorValues };

        foreach(var series in allSeries)
        {
            // 查找需要移除的点
            var pointsToRemove = series.Where(p => p.DateTime < retentionThreshold).ToList();
            foreach(var point in pointsToRemove)
            {
                series.Remove(point);
            }
            // 使用 GChartValues 时,批量移除可能更高效,但简单 Remove 也能工作
            // 或者更高效的方式是重建 ChartValues,只包含有效数据点
        }

        // 如果数据量非常大,更激进的方法是,当点数超过阈值时,直接清空旧数据段
        // int maxPointsPerSeries = 1000;
        // foreach(var series in allSeries) {
        //     while (series.Count > maxPointsPerSeries) {
        //         series.RemoveAt(0); // 移除最旧的点
        //     }
        // }
    }

    // INotifyPropertyChanged 实现
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged(string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    // (你的数据库帮助类 DBHelper 和 SensingDataPoint 类定义保持不变)
    // public class DBHelper { ... public List<SensingDataPoint> GetNewSensingDataSince(DateTime time) { ... } ... }
    // public class SensingDataPoint { public int ID { get; set; } public DateTime Time { get; set; } public double Value { get; set; } }
}

代码说明:

  1. GChartValues<DateTimePoint> :替代了原来的 List<double>GChartValuesLiveCharts.Geared 提供的高性能集合,特别适合数据点频繁增删的场景。DateTimePoint 存储了时间和值。
  2. DateTimeFormatter :用于设置 X 轴,告诉图表如何将内部的 DateTime.Ticks (一个 double 类型) 转换成用户看到的日期时间字符串。
  3. 数据更新逻辑 (UpdateChartDataCallback, ProcessNewData)
    • 使用 System.Threading.Timer 定期触发数据更新。
    • ProcessNewData 中,根据传感器 ID (group.Key) 将新的数据点 new DateTimePoint(point.Time, point.Value) 添加到对应的 GChartValues 集合中。注意:这里没有添加任何 0 或 null 到其他传感器的集合里! 哪个传感器有数据,就只给哪个传感器的集合加点。
    • 使用 Application.Current.Dispatcher.Invoke 确保 UI 相关的集合操作在主线程执行。
  4. X轴滚动 (AxisMin, AxisMax, UpdateAxisRange) : 通过绑定 X 轴的 MinValueMaxValue 属性到 ViewModel 的 AxisMinAxisMax,并定时更新这两个值,可以实现图表窗口随时间自动滚动的效果。
  5. 数据清理 (RemoveOldDataPoints) : 对于实时图表,数据会无限增长。需要定期移除旧的数据点,防止内存溢出和性能下降。可以根据时间戳或最大点数来清理。
  6. 线程安全 : 添加了简单的 lock 和标志位 _isUpdating 来避免数据更新任务并发执行可能导致的问题。实际应用中可能需要更健壮的并发控制。
  7. 数据库查询调整 : GetSensingData 方法需要调整,不再是简单取 LIMIT N,而是获取某个时间点之后的新增数据 (GetNewSensingDataSince,你需要自己实现这个逻辑,比如记录上次查询的最大时间戳)。

步骤二:配置 XAML

在你的 WPF 窗口或用户控件的 XAML 里,设置 CartesianChart

<Window x:Class="YourNamespace.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:YourNamespace"
        xmlns:lvc="clr-namespace:LiveCharts.Wpf;assembly=LiveCharts.Wpf"
        mc:Ignorable="d"
        Title="Live Sensor Chart" Height="450" Width="800">
    <Grid>
        <lvc:CartesianChart LegendLocation="Right">
            <!-- X 轴设置 -->
            <lvc:CartesianChart.AxisX>
                <lvc:Axis Title="Time"
                          LabelFormatter="{Binding DateTimeFormatter}"
                          MinValue="{Binding AxisMin}"
                          MaxValue="{Binding AxisMax}">
                    <lvc:Axis.Separator>
                        <!-- 让 X 轴的分割线细一点,或者去掉 -->
                        <lvc:Separator StrokeThickness="0.5" StrokeDashArray="2" Stroke="LightGray" />
                    </lvc:Axis.Separator>
                </lvc:Axis>
            </lvc:CartesianChart.AxisX>

            <!-- Y 轴设置 -->
            <lvc:CartesianChart.AxisY>
                <lvc:Axis Title="Value"></lvc:Axis>
            </lvc:CartesianChart.AxisY>

            <!-- 数据系列 -->
            <lvc:CartesianChart.Series>
                <lvc:LineSeries Title="Sensor 1"
                                Values="{Binding ID1SensorValues}"
                                PointGeometry="{x:Null}"
                                LineSmoothness="0" />
                                <!-- Fill="Transparent" -->
                                <!-- StrokeThickness="2" -->

                <lvc:LineSeries Title="Sensor 2"
                                Values="{Binding ID2SensorValues}"
                                PointGeometry="{x:Null}"
                                LineSmoothness="0" />

                <lvc:LineSeries Title="Sensor 3"
                                Values="{Binding ID3SensorValues}"
                                PointGeometry="{x:Null}"
                                LineSmoothness="0" />

                <lvc:LineSeries Title="Sensor 4"
                                Values="{Binding ID4SensorValues}"
                                PointGeometry="{x:Null}"
                                LineSmoothness="0" />

                <lvc:LineSeries Title="Sensor 5"
                                Values="{Binding ID5SensorValues}"
                                PointGeometry="{x:Null}"
                                LineSmoothness="0" />
            </lvc:CartesianChart.Series>
        </lvc:CartesianChart>
    </Grid>
</Window>

XAML 说明:

  1. lvc:CartesianChart : LiveCharts 的图表控件。
  2. AxisX :
    • LabelFormatter="{Binding DateTimeFormatter}": 绑定到 ViewModel 的格式化器,用于显示时间标签。
    • MinValue="{Binding AxisMin}", MaxValue="{Binding AxisMax}": 绑定到 ViewModel 的属性,控制X轴的可见范围,实现滚动效果。
  3. lvc:LineSeries :
    • Values="{Binding IDxSensorValues}": 将每个 LineSeries 的数据源绑定到 ViewModel 中对应的 GChartValues<DateTimePoint> 集合。
    • PointGeometry="{x:Null}": 不显示数据点本身,只显示连线。如果需要显示点,可以去掉或设置成其他几何形状。
    • LineSmoothness="0": 直线连接,如果需要平滑曲线可以设为大于 0 的值。

步骤三:设置 DataContext

别忘了在你的 Window 或 UserControl 的后台代码或者 XAML 里设置 DataContext 指向你的 MainViewModel 实例。

后台代码 (例如 Window 的构造函数):

public MainWindow()
{
    InitializeComponent();
    DataContext = new MainViewModel(); // 设置 DataContext
}

现在运行你的程序,图表应该就能按照预期工作了:每条线只会在它拥有实际数据点的时间段内绘制,其他时间段则是空白,不会出现强制连到起点或用 0 值填充的情况。

进阶技巧与安全建议

  1. 性能优化 :

    • LiveCharts.Geared : 对于高频数据更新,Geared 版本性能更好。确保已安装并使用 GChartValues
    • 数据抽样/聚合 : 如果数据源非常密集(例如每秒几百个点),在添加到图表前进行适当的抽样(比如每秒只取一个点)或聚合(比如计算每秒的平均值)可以显著提升性能和图表的可读性。
    • 数据清理策略 : 合理设置数据保留窗口和清理逻辑(RemoveOldDataPoints),避免图表集合无限增大。
  2. 数据库查询优化 :

    • 确保 sSensorTime 列有索引,以加速按时间范围查询。
    • 避免在定时器回调中进行长时间的数据库操作,考虑使用异步方法 (async/await) 进行数据库查询,防止阻塞更新线程。
    • 参数化查询 : 你的原始代码 $"SELECT ... ORDER BY {sSensorTime} DESC LIMIT {_iCount}" 看起来像是直接拼接 SQL。强烈建议使用参数化查询,防止 SQL 注入攻击。例如,使用 MySqlCommand.Parameters.AddWithValue()
    // 示例:使用参数化查询 (伪代码,需根据你的 DBHelper 调整)
    string query = 
    // 示例:使用参数化查询 (伪代码,需根据你的 DBHelper 调整)
    string query = $"SELECT {sSensorId}, {sSensorTime}, {sSensorValue} FROM {sSensorTable} WHERE {sSensorTime} > @lastTime ORDER BY {sSensorTime} ASC"; // ASC 通常更适合处理增量数据
    using (var command = new MySqlCommand(query, connection))
    {
        command.Parameters.AddWithValue("@lastTime", lastTimestamp); // lastTimestamp 是上次查询的最大时间
        // ... 执行查询 ...
    }
    
    quot;SELECT {sSensorId}, {sSensorTime}, {sSensorValue} FROM {sSensorTable} WHERE {sSensorTime} > @lastTime ORDER BY {sSensorTime} ASC"
    ; // ASC 通常更适合处理增量数据 using (var command = new MySqlCommand(query, connection)) { command.Parameters.AddWithValue("@lastTime", lastTimestamp); // lastTimestamp 是上次查询的最大时间 // ... 执行查询 ... }
  3. UI 响应性 :

    • 确保所有对 ObservableCollectionChartValues 的修改都在 UI 线程上完成(使用 Dispatcher.InvokeDispatcher.BeginInvoke)。
    • 复杂的数据处理(如聚合、转换)应在后台线程完成,只把最终结果传给 UI 线程更新集合。
  4. 错误处理 : 在数据库访问、数据处理和 UI 更新中加入恰当的 try-catch 块,记录或显示错误信息,避免程序崩溃。

通过采用基于时间的 X 轴和为每个数据系列维护独立的数据点集合(只在有数据时添加),并结合 LiveCharts 的特性,就能完美解决你遇到的问题,创建出线条只在特定时间段出现的实时图表。