using Logger; using OxyPlot; using OxyPlot.Annotations; using OxyPlot.Axes; using OxyPlot.Legends; using OxyPlot.Series; using Prism.Commands; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Text; using System.Windows.Input; using CurveModule.ViewModels.Dialogs; using UIShare.ViewModelBase; using UIShare.GlobalVariable; namespace CurveModule.ViewModels { /// /// 曲线回读:加载 RecordView 导出的 CSV 文件,按 (MonitorName, Scope) 分组绘制曲线。 /// public class CurveRecallViewModel : NavigateViewModelBase, IRegionMemberLifetime, IDisposable { #region 私有字段 private GlobalConfig _globalConfig; #endregion #region 属性 public bool KeepAlive => true; public PlotModel Plot { get; } /// 已加载的曲线通道列表 public ObservableCollection Channels { get; } = new(); private CurveRecallChannelVM? _selectedChannel; public CurveRecallChannelVM? SelectedChannel { get => _selectedChannel; set => SetProperty(ref _selectedChannel, value); } private string _statusMessage = "请加载 CSV 文件以回读曲线"; public string StatusMessage { get => _statusMessage; set => SetProperty(ref _statusMessage, value); } #endregion #region 命令 public ICommand LoadCsvCommand { get; } public ICommand ResetViewCommand { get; } public ICommand ClearCommand { get; } public ICommand StatisticsCommand { get; } public ICommand SaveCommand { get; } public ICommand LoadCommand { get; } public ICommand CaptureCommand { get; } #endregion #region 私有字段 // 颜色调色盘(与 MonitorView 一致) private static readonly OxyColor[] _palette = { OxyColors.SteelBlue, OxyColors.IndianRed, OxyColors.SeaGreen, OxyColors.DarkOrange, OxyColors.MediumPurple, OxyColors.Goldenrod, OxyColors.Teal, OxyColors.Crimson, OxyColors.OliveDrab }; private int _colorIndex; // 双光标截取 private LineAnnotation? _cursor1; private LineAnnotation? _cursor2; private LineAnnotation? _draggingCursor; private List _allRecords = new(); #endregion /// 截取按钮文本(随状态切换) private string _captureButtonText = "开始截取图形"; public string CaptureButtonText { get => _captureButtonText; set => SetProperty(ref _captureButtonText, value); } public CurveRecallViewModel(IContainerProvider containerProvider) : base(containerProvider) { _globalConfig = containerProvider.Resolve(); Plot = BuildEmptyPlot(); LoadCsvCommand = new DelegateCommand(OnLoadCsv); ResetViewCommand = new DelegateCommand(OnResetView); ClearCommand = new DelegateCommand(OnClear); StatisticsCommand = new DelegateCommand(OnStatistics); SaveCommand = new DelegateCommand(OnSave); LoadCommand = new DelegateCommand(Onload); CaptureCommand = new DelegateCommand(OnToggleCapture); } #region 命令处理 private void OnSave() { ConfigService.SaveGlobalConfig(_globalConfig); } private void Onload() { if (File.Exists(_globalConfig.DefaultCSVFilePath)) { OnLoadCsv(_globalConfig.DefaultCSVFilePath); } } /// /// 打开 CSV 文件对话框,解析数据并按 (MonitorName, Scope) 分组绘制曲线。 /// private void OnLoadCsv(string filename) { try { // 1. 统一处理 filename if (string.IsNullOrEmpty(filename)) { var dlg = new Microsoft.Win32.OpenFileDialog { Filter = "CSV 文件 (*.csv)|*.csv|所有文件|*.*", Title = "选择要加载的监测数据 CSV 文件" }; if (dlg.ShowDialog() != true) return; // 将弹窗选中的路径统一赋给 filename filename = dlg.FileName; _globalConfig.DefaultCSVFilePath = filename; } // 2. 统一解析数据 List records = ParseCsv(filename); _allRecords = records; if (records.Count == 0) { StatusMessage = "CSV 文件为空或格式不正确"; return; } // 3. 按 (MonitorName, Scope) 分组 var groups = records.GroupBy(r => (r.MonitorName, r.Scope)) .OrderBy(g => g.Key.MonitorName) .ThenBy(g => g.Key.Scope); // 清空旧数据 ClearPlotAndChannels(); foreach (var group in groups) { var color = _palette[_colorIndex % _palette.Length]; _colorIndex++; string displayName = string.IsNullOrEmpty(group.Key.Scope) ? group.Key.MonitorName : $"{group.Key.MonitorName} [{group.Key.Scope}]"; var channel = new CurveRecallChannelVM { MonitorName = group.Key.MonitorName, Scope = group.Key.Scope, DisplayName = displayName, Color = color, IsDisplayed = true }; // 创建 LineSeries 并填充数据点 channel.Series = new LineSeries { Title = displayName, Color = color, StrokeThickness = 1.5 }; // 按时间排序 var sortedPoints = group.OrderBy(r => r.CreateTime); foreach (var point in sortedPoints) { channel.Series.Points.Add(new DataPoint(point.CreateTime.ToOADate(), point.MonitorValue)); } channel.PropertyChanged += OnChannelPropertyChanged; Plot.Series.Add(channel.Series); Channels.Add(channel); } Plot.InvalidatePlot(true); // 4. 统一使用 filename 变量进行日志和状态更新 StatusMessage = $"已加载 {Path.GetFileName(filename)},共 {Channels.Count} 条曲线,{records.Count} 个数据点"; LoggerHelper.Info($"曲线回读:加载 {filename},{Channels.Count} 条曲线,{records.Count} 个数据点"); } catch (Exception ex) { LoggerHelper.Error($"加载 CSV 失败:{ex.Message}"); StatusMessage = $"加载失败:{ex.Message}"; } } private void OnResetView() { Plot.ResetAllAxes(); Plot.InvalidatePlot(false); StatusMessage = "视图已复原"; } private void OnClear() { RemoveCursors(); CaptureButtonText = "开始截取图形"; ClearPlotAndChannels(); _allRecords.Clear(); Plot.InvalidatePlot(true); StatusMessage = "已清空,请重新加载 CSV 文件"; } /// /// 对每条曲线计算数学统计(最大、最小、平均、斜率、积分),弹窗显示。 /// private void OnStatistics() { if (Channels.Count == 0) { ShowInfoMessageBox("请先加载 CSV 文件", () => { }); return; } var results = new ObservableCollection(); foreach (var channel in Channels) { if (channel.Series == null || channel.Series.Points.Count == 0) continue; results.Add(ComputeStatistics(channel)); } if (results.Count == 0) { ShowInfoMessageBox("没有可统计的曲线数据", () => { }); return; } var parameters = new DialogParameters(); parameters.Add("Results", results); _dialogService.ShowDialog("CurveStatistics", parameters, _ => { }); StatusMessage = $"已计算 {results.Count} 条曲线的数学统计"; } #endregion #region CSV 解析 /// /// 解析 RecordView 导出的 CSV 文件,返回监测记录列表。 /// private static List ParseCsv(string path) { var results = new List(); using var sr = new StreamReader(path, Encoding.UTF8); // 读取表头 string? headerLine = sr.ReadLine(); if (headerLine == null) return results; var headers = ParseCsvLine(headerLine); int monitorNameIdx = headers.FindIndex(h => h.Trim() == "MonitorName"); int monitorValueIdx = headers.FindIndex(h => h.Trim() == "MonitorValue"); int scopeIdx = headers.FindIndex(h => h.Trim() == "Scope"); int createTimeIdx = headers.FindIndex(h => h.Trim() == "CreateTime"); if (monitorNameIdx < 0 || monitorValueIdx < 0 || scopeIdx < 0 || createTimeIdx < 0) return results; int maxIdx = Math.Max(monitorNameIdx, Math.Max(monitorValueIdx, Math.Max(scopeIdx, createTimeIdx))); string? line; while ((line = sr.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; var fields = ParseCsvLine(line); if (fields.Count <= maxIdx) continue; if (!double.TryParse(fields[monitorValueIdx].Trim(), out double value)) continue; if (!DateTime.TryParse(fields[createTimeIdx].Trim(), out DateTime createTime)) continue; results.Add(new MonitorRecord { MonitorName = fields[monitorNameIdx].Trim(), MonitorValue = value, Scope = fields[scopeIdx].Trim(), CreateTime = createTime }); } return results; } /// /// 解析单行 CSV,正确处理引号转义。 /// private static List ParseCsvLine(string line) { var fields = new List(); bool inQuotes = false; var sb = new StringBuilder(); for (int i = 0; i < line.Length; i++) { char c = line[i]; if (inQuotes) { if (c == '"') { if (i + 1 < line.Length && line[i + 1] == '"') { sb.Append('"'); i++; } else { inQuotes = false; } } else { sb.Append(c); } } else { if (c == '"') { inQuotes = true; } else if (c == ',') { fields.Add(sb.ToString()); sb.Clear(); } else { sb.Append(c); } } } fields.Add(sb.ToString()); return fields; } #endregion #region OxyPlot 构建 /// /// 构建空的 OxyPlot 模型(与 MonitorView 完全一致的配置)。 /// private static PlotModel BuildEmptyPlot() { var pm = new PlotModel { Title = "曲线回读", PlotAreaBorderColor = OxyColors.LightGray, Background = OxyColors.White }; pm.Axes.Add(new DateTimeAxis { Position = AxisPosition.Bottom, Title = "时间", StringFormat = "HH:mm:ss", MajorGridlineStyle = LineStyle.Dot, MinorGridlineStyle = LineStyle.None }); pm.Axes.Add(new LinearAxis { Position = AxisPosition.Left, Title = "值", MajorGridlineStyle = LineStyle.Dot, MinorGridlineStyle = LineStyle.None }); pm.Legends.Add(new Legend { LegendPosition = LegendPosition.RightTop, LegendBackground = OxyColor.FromAColor(200, OxyColors.White), LegendBorder = OxyColors.LightGray }); return pm; } #endregion #region 辅助方法 /// /// 对单条曲线计算数学统计:最大值、最小值、平均值、线性回归斜率、梯形积分。 /// 斜率单位:值/秒;积分单位:值×秒。 /// private static CurveStatisticsResult ComputeStatistics(CurveRecallChannelVM channel) { var points = channel.Series!.Points; int n = points.Count; if (n == 0) return new CurveStatisticsResult { DisplayName = channel.DisplayName, PointCount = 0 }; double max = double.MinValue; double min = double.MaxValue; double sumY = 0; // 线性回归累计量 double sumX = 0, sumXY = 0, sumX2 = 0; // 梯形积分累计量 double integral = 0; for (int i = 0; i < n; i++) { double x = points[i].X; // OADate 天数 double y = points[i].Y; if (y > max) max = y; if (y < min) min = y; sumY += y; sumX += x; sumXY += x * y; sumX2 += x * x; if (i > 0) { double dx = x - points[i - 1].X; integral += dx * (y + points[i - 1].Y) / 2.0; } } double average = sumY / n; // 线性回归斜率(单位:值 / OADate 天) double slopePerDay = 0; double denominator = n * sumX2 - sumX * sumX; if (Math.Abs(denominator) > 1e-30) { slopePerDay = (n * sumXY - sumX * sumY) / denominator; } // 转换为 值/秒 double slopePerSecond = slopePerDay / 86400.0; // 积分从 OADate·值 转换为 秒·值 double integralSeconds = integral * 86400.0; return new CurveStatisticsResult { DisplayName = channel.DisplayName, PointCount = n, Max = max, Min = min, Average = average, Slope = slopePerSecond, Integral = integralSeconds }; } private void OnChannelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName != nameof(CurveRecallChannelVM.IsDisplayed)) return; if (sender is not CurveRecallChannelVM channel) return; if (channel.IsDisplayed) { if (channel.Series != null && !Plot.Series.Contains(channel.Series)) { Plot.Series.Add(channel.Series); } } else { if (channel.Series != null) { Plot.Series.Remove(channel.Series); } } Plot.InvalidatePlot(true); } private void ClearPlotAndChannels() { foreach (var ch in Channels) { ch.PropertyChanged -= OnChannelPropertyChanged; } Plot.Series.Clear(); Channels.Clear(); _colorIndex = 0; } #endregion #region 双光标截取 /// /// 切换截取模式:添加/移除双光标,结束时导出选中范围 CSV。 /// private void OnToggleCapture() { if (_cursor1 == null) { CreateCursors(); CaptureButtonText = "结束截取图形"; } else { double x1 = _cursor1.X; double x2 = _cursor2!.X; double minX = Math.Min(x1, x2); double maxX = Math.Max(x1, x2); RemoveCursors(); CaptureButtonText = "开始截取图形"; ExportCapturedCsv(minX, maxX); } } /// /// 在图表上添加两条可拖拽的竖直光标。 /// private void CreateCursors() { if (Channels.Count == 0) return; var dateAxis = Plot.Axes.OfType().FirstOrDefault(); if (dateAxis == null) return; double xMin = dateAxis.ActualMinimum; double xMax = dateAxis.ActualMaximum; double range = xMax - xMin; if (range <= 0) return; _cursor1 = new LineAnnotation { Type = LineAnnotationType.Vertical, X = xMin + range * 0.3, Color = OxyColors.SteelBlue, StrokeThickness = 2, LineStyle = LineStyle.Dash, Text = "▼", TextColor = OxyColors.SteelBlue, TextHorizontalAlignment = HorizontalAlignment.Center, TextVerticalAlignment = VerticalAlignment.Top }; _cursor2 = new LineAnnotation { Type = LineAnnotationType.Vertical, X = xMin + range * 0.7, Color = OxyColors.SteelBlue, StrokeThickness = 2, LineStyle = LineStyle.Dash, Text = "▼", TextColor = OxyColors.SteelBlue, TextHorizontalAlignment = HorizontalAlignment.Center, TextVerticalAlignment = VerticalAlignment.Top }; _cursor1.MouseDown += OnCursorMouseDown; _cursor1.MouseUp += OnCursorMouseUp; _cursor2.MouseDown += OnCursorMouseDown; _cursor2.MouseUp += OnCursorMouseUp; Plot.MouseDown += OnPlotMouseMove; Plot.Annotations.Add(_cursor1); Plot.Annotations.Add(_cursor2); Plot.InvalidatePlot(true); StatusMessage = "已添加双光标,拖拽竖线选择范围,完成后点击「结束截取图形」"; } /// /// 移除双光标并清理事件。 /// private void RemoveCursors() { if (_cursor1 != null) { _cursor1.MouseDown -= OnCursorMouseDown; _cursor1.MouseUp -= OnCursorMouseUp; Plot.Annotations.Remove(_cursor1); _cursor1 = null; } if (_cursor2 != null) { _cursor2.MouseDown -= OnCursorMouseDown; _cursor2.MouseUp -= OnCursorMouseUp; Plot.Annotations.Remove(_cursor2); _cursor2 = null; } Plot.MouseDown -= OnPlotMouseMove; _draggingCursor = null; Plot.InvalidatePlot(true); } private void OnCursorMouseDown(object sender, OxyMouseDownEventArgs e) { if (sender is LineAnnotation line) _draggingCursor = line; } private void OnCursorMouseUp(object sender, OxyMouseEventArgs e) { _draggingCursor = null; } private void OnPlotMouseMove(object sender, OxyMouseDownEventArgs e) { if (_draggingCursor == null) return; var position = ((OxyMouseEventArgs)e).Position; var axis = Plot.Axes.OfType().FirstOrDefault(); if (axis == null) return; double dataX = axis.InverseTransform(position.X, position.Y, null).X; // 限制光标不超出数据范围 double xMin = axis.ActualMinimum; double xMax = axis.ActualMaximum; dataX = Math.Max(xMin, Math.Min(xMax, dataX)); _draggingCursor.X = dataX; _draggingCursor.Text = $"▼ {DateTime.FromOADate(dataX):HH:mm:ss}"; Plot.InvalidatePlot(true); } /// /// 导出双光标选定时间范围内的原始记录到 CSV。 /// private void ExportCapturedCsv(double minXOaDate, double maxXOaDate) { if (_allRecords.Count == 0) { ShowInfoMessageBox("没有加载数据,无法导出", () => { }); return; } var startTime = DateTime.FromOADate(minXOaDate); var endTime = DateTime.FromOADate(maxXOaDate); var filtered = _allRecords .Where(r => r.CreateTime >= startTime && r.CreateTime <= endTime) .OrderBy(r => r.CreateTime) .ToList(); if (filtered.Count == 0) { ShowInfoMessageBox("选定范围内没有数据", () => { }); return; } var dlg = new Microsoft.Win32.SaveFileDialog { Filter = "CSV 文件 (*.csv)|*.csv", Title = "导出截取范围数据", FileName = $"captured_{DateTime.Now:yyyyMMdd_HHmmss}.csv" }; if (dlg.ShowDialog() != true) return; using var sw = new StreamWriter(dlg.FileName, false, Encoding.UTF8); sw.WriteLine("MonitorName,MonitorValue,Scope,CreateTime"); foreach (var r in filtered) { sw.WriteLine($"{EscapeCsvField(r.MonitorName)},{r.MonitorValue},{EscapeCsvField(r.Scope)},{r.CreateTime:yyyy-MM-dd HH:mm:ss.fff}"); } StatusMessage = $"已导出 {filtered.Count} 条记录到 {Path.GetFileName(dlg.FileName)}"; LoggerHelper.Info($"导出截取范围 CSV:{dlg.FileName},{filtered.Count} 条记录"); } private static string EscapeCsvField(string field) { if (string.IsNullOrEmpty(field)) return ""; if (field.Contains(',') || field.Contains('"') || field.Contains('\n')) return $"\"{field.Replace("\"", "\"\"")}\""; return field; } #endregion #region 生命周期 public override void OnNavigatedTo(NavigationContext navigationContext) { } public void Dispose() { RemoveCursors(); ClearPlotAndChannels(); } #endregion } /// /// CSV 监测记录(单行数据)。 /// internal class MonitorRecord { public string MonitorName { get; set; } = string.Empty; public double MonitorValue { get; set; } public string Scope { get; set; } = string.Empty; public DateTime CreateTime { get; set; } } /// /// 曲线回读通道:MonitorName + Scope 唯一标识一条曲线。 /// public class CurveRecallChannelVM : Prism.Mvvm.BindableBase { public string MonitorName { get; set; } = string.Empty; public string Scope { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public OxyColor Color { get; set; } = OxyColors.SteelBlue; private bool _isDisplayed = true; public bool IsDisplayed { get => _isDisplayed; set => SetProperty(ref _isDisplayed, value); } public LineSeries? Series { get; set; } } }