回读图像功能优化

This commit is contained in:
“hsc”
2026-07-30 16:33:32 +08:00
parent f350058f53
commit e90dc805fc
11 changed files with 674 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
using Logger;
using OxyPlot;
using OxyPlot.Annotations;
using OxyPlot.Axes;
using OxyPlot.Legends;
using OxyPlot.Series;
@@ -11,7 +12,9 @@ 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
{
@@ -20,6 +23,9 @@ namespace CurveModule.ViewModels
/// </summary>
public class CurveRecallViewModel : NavigateViewModelBase, IRegionMemberLifetime, IDisposable
{
#region
private GlobalConfig _globalConfig;
#endregion
#region
public bool KeepAlive => true;
@@ -47,6 +53,11 @@ namespace CurveModule.ViewModels
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
@@ -58,39 +69,84 @@ namespace CurveModule.ViewModels
OxyColors.Teal, OxyColors.Crimson, OxyColors.OliveDrab
};
private int _colorIndex;
// 双光标截取
private LineAnnotation? _cursor1;
private LineAnnotation? _cursor2;
private LineAnnotation? _draggingCursor;
private List<MonitorRecord> _allRecords = new();
#endregion
/// <summary>截取按钮文本(随状态切换)</summary>
private string _captureButtonText = "开始截取图形";
public string CaptureButtonText
{
get => _captureButtonText;
set => SetProperty(ref _captureButtonText, value);
}
public CurveRecallViewModel(IContainerProvider containerProvider) : base(containerProvider)
{
_globalConfig = containerProvider.Resolve<GlobalConfig>();
Plot = BuildEmptyPlot();
LoadCsvCommand = new DelegateCommand(OnLoadCsv);
LoadCsvCommand = new DelegateCommand<string>(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);
}
}
/// <summary>
/// 打开 CSV 文件对话框,解析数据并按 (MonitorName, Scope) 分组绘制曲线。
/// </summary>
private void OnLoadCsv()
private void OnLoadCsv(string filename)
{
try
{
var dlg = new Microsoft.Win32.OpenFileDialog
// 1. 统一处理 filename
if (string.IsNullOrEmpty(filename))
{
Filter = "CSV 文件 (*.csv)|*.csv|所有文件|*.*",
Title = "选择要加载的监测数据 CSV 文件"
};
if (dlg.ShowDialog() != true) return;
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<MonitorRecord> records = ParseCsv(filename);
_allRecords = records;
var records = ParseCsv(dlg.FileName);
if (records.Count == 0)
{
StatusMessage = "CSV 文件为空或格式不正确";
return;
}
// 按 (MonitorName, Scope) 分组
// 3. 按 (MonitorName, Scope) 分组
var groups = records.GroupBy(r => (r.MonitorName, r.Scope))
.OrderBy(g => g.Key.MonitorName)
.ThenBy(g => g.Key.Scope);
@@ -138,8 +194,10 @@ namespace CurveModule.ViewModels
}
Plot.InvalidatePlot(true);
StatusMessage = $"已加载 {Path.GetFileName(dlg.FileName)},共 {Channels.Count} 条曲线,{records.Count} 个数据点";
LoggerHelper.Info($"曲线回读:加载 {dlg.FileName}{Channels.Count} 条曲线,{records.Count} 个数据点");
// 4. 统一使用 filename 变量进行日志和状态更新
StatusMessage = $"已加载 {Path.GetFileName(filename)},共 {Channels.Count} 条曲线,{records.Count} 个数据点";
LoggerHelper.Info($"曲线回读:加载 {filename}{Channels.Count} 条曲线,{records.Count} 个数据点");
}
catch (Exception ex)
{
@@ -157,10 +215,43 @@ namespace CurveModule.ViewModels
private void OnClear()
{
RemoveCursors();
CaptureButtonText = "开始截取图形";
ClearPlotAndChannels();
_allRecords.Clear();
Plot.InvalidatePlot(true);
StatusMessage = "已清空,请重新加载 CSV 文件";
}
/// <summary>
/// 对每条曲线计算数学统计(最大、最小、平均、斜率、积分),弹窗显示。
/// </summary>
private void OnStatistics()
{
if (Channels.Count == 0)
{
ShowInfoMessageBox("请先加载 CSV 文件", () => { });
return;
}
var results = new ObservableCollection<CurveStatisticsResult>();
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
@@ -302,6 +393,79 @@ namespace CurveModule.ViewModels
#endregion
#region
/// <summary>
/// 对单条曲线计算数学统计:最大值、最小值、平均值、线性回归斜率、梯形积分。
/// 斜率单位:值/秒;积分单位:值×秒。
/// </summary>
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;
@@ -334,6 +498,194 @@ namespace CurveModule.ViewModels
Channels.Clear();
_colorIndex = 0;
}
#endregion
#region
/// <summary>
/// 切换截取模式:添加/移除双光标,结束时导出选中范围 CSV。
/// </summary>
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);
}
}
/// <summary>
/// 在图表上添加两条可拖拽的竖直光标。
/// </summary>
private void CreateCursors()
{
if (Channels.Count == 0) return;
var dateAxis = Plot.Axes.OfType<DateTimeAxis>().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 = "已添加双光标,拖拽竖线选择范围,完成后点击「结束截取图形」";
}
/// <summary>
/// 移除双光标并清理事件。
/// </summary>
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<DateTimeAxis>().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);
}
/// <summary>
/// 导出双光标选定时间范围内的原始记录到 CSV。
/// </summary>
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
@@ -343,6 +695,7 @@ namespace CurveModule.ViewModels
public void Dispose()
{
RemoveCursors();
ClearPlotAndChannels();
}
#endregion

View File

@@ -0,0 +1,95 @@
using Prism.Commands;
using System.Collections.ObjectModel;
using System.Windows.Input;
using UIShare.PubEvent;
using UIShare.ViewModelBase;
namespace CurveModule.ViewModels.Dialogs
{
/// <summary>
/// 曲线统计结果弹窗 ViewModel。
/// </summary>
public class CurveStatisticsViewModel : DialogViewModelBase
{
#region
private string _title = "曲线数学统计";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public ObservableCollection<CurveStatisticsResult> Results { get; } = new();
#endregion
#region
public ICommand CloseCommand { get; }
#endregion
public CurveStatisticsViewModel(IContainerProvider containerProvider) : base(containerProvider)
{
CloseCommand = new DelegateCommand(Close);
}
private void Close()
{
RequestClose.Invoke(ButtonResult.OK);
}
#region Prism Dialog
public override void OnDialogClosed()
{
_eventAggregator.GetEvent<OverlayEvent>().Publish(false);
}
public override void OnDialogOpened(IDialogParameters parameters)
{
_eventAggregator.GetEvent<OverlayEvent>().Publish(true);
if (parameters.ContainsKey("Results"))
{
var list = parameters.GetValue<ObservableCollection<CurveStatisticsResult>>("Results");
Results.Clear();
foreach (var item in list)
Results.Add(item);
}
}
#endregion
}
/// <summary>
/// 单条曲线的数学统计结果。
/// </summary>
public class CurveStatisticsResult
{
/// <summary>曲线名称</summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>数据点数</summary>
public int PointCount { get; set; }
/// <summary>最大值</summary>
public double Max { get; set; }
/// <summary>最小值</summary>
public double Min { get; set; }
/// <summary>平均值</summary>
public double Average { get; set; }
/// <summary>线性回归斜率(单位/秒)</summary>
public double Slope { get; set; }
/// <summary>梯形积分值(单位·秒)</summary>
public double Integral { get; set; }
public string MaxText => Max.ToString("G6");
public string MinText => Min.ToString("G6");
public string AverageText => Average.ToString("G6");
public string SlopeText => Slope.ToString("G6");
public string IntegralText => Integral.ToString("G6");
}
}