diff --git a/COFTester/Controls/RealtimeForceChart.cs b/COFTester/Controls/RealtimeForceChart.cs index 45f16ba..b9dc31f 100644 --- a/COFTester/Controls/RealtimeForceChart.cs +++ b/COFTester/Controls/RealtimeForceChart.cs @@ -7,8 +7,27 @@ using LiveChartsCore.Defaults; namespace COFTester.Controls; +public enum RealtimeForceChartPart +{ + Full, + LeftAxis, + Plot, + RightAxis +} + public sealed class RealtimeForceChart : FrameworkElement { + private const double PlotTop = 36; + private const double PlotBottomMargin = 68; + private const double FullChartLeftMargin = 78; + private const double FullChartRightMargin = 142; + + public static readonly DependencyProperty ChartPartProperty = DependencyProperty.Register( + nameof(ChartPart), + typeof(RealtimeForceChartPart), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(RealtimeForceChartPart.Full, FrameworkPropertyMetadataOptions.AffectsRender)); + public static readonly DependencyProperty SamplesProperty = DependencyProperty.Register( nameof(Samples), typeof(IEnumerable), @@ -72,6 +91,12 @@ public sealed class RealtimeForceChart : FrameworkElement private readonly Brush _pointBrush = new SolidColorBrush(Color.FromRgb(18, 22, 25)); private readonly Pen _pointPen = new(new SolidColorBrush(Color.FromRgb(247, 249, 251)), 1.8); + public RealtimeForceChartPart ChartPart + { + get => (RealtimeForceChartPart)GetValue(ChartPartProperty); + set => SetValue(ChartPartProperty, value); + } + public IEnumerable? Samples { get => (IEnumerable?)GetValue(SamplesProperty); @@ -126,28 +151,80 @@ public sealed class RealtimeForceChart : FrameworkElement var width = ActualWidth; var height = ActualHeight; - if (width < 120 || height < 90) + var minimumWidth = ChartPart == RealtimeForceChartPart.Plot ? 1 : 120; + if (width < minimumWidth || height < 90) { return; } - var plot = new Rect(78, 36, Math.Max(1, width - 220), Math.Max(1, height - 104)); var samples = ReadPoints(Samples); - var currentPoint = ReadPoints(CurrentPointSamples).LastOrDefault(); var xMax = ResolveAxisMax(XMax, TravelMm, samples.Select(point => point.X)); var yMax = ResolveAxisMax(YMax, 0.5, samples.Select(point => point.Y)); drawingContext.DrawRectangle(_chartBackgroundBrush, _chartBorderPen, new Rect(0.5, 0.5, Math.Max(0, width - 1), Math.Max(0, height - 1))); - DrawGridAndAxes(drawingContext, plot, xMax, yMax); + + switch (ChartPart) + { + case RealtimeForceChartPart.LeftAxis: + DrawLeftAxis(drawingContext, width, height, yMax); + break; + case RealtimeForceChartPart.Plot: + DrawPlot(drawingContext, width, height, xMax, yMax, samples); + break; + case RealtimeForceChartPart.RightAxis: + DrawRightAxis(drawingContext, width, height, yMax); + break; + default: + DrawFullChart(drawingContext, width, height, xMax, yMax, samples); + break; + } + } + + private void DrawFullChart(DrawingContext drawingContext, double width, double height, double xMax, double yMax, IReadOnlyList samples) + { + var plot = new Rect( + FullChartLeftMargin, + PlotTop, + Math.Max(1, width - FullChartLeftMargin - FullChartRightMargin), + Math.Max(1, height - PlotTop - PlotBottomMargin)); + var currentPoint = ReadPoints(CurrentPointSamples).LastOrDefault(); + + DrawPlotGridAndXAxis(drawingContext, plot, xMax, yMax); + DrawYAxisLabels(drawingContext, plot, yMax, leftAxis: true, labelX: plot.Left - 10); + DrawYAxisLabels(drawingContext, plot, yMax, leftAxis: false, labelX: plot.Right + 8); DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(PeakLineSamples), _peakPen); DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(AverageLineSamples), _averagePen); DrawCurve(drawingContext, plot, xMax, yMax, samples); + DrawCurrentPoint(drawingContext, plot, xMax, yMax, currentPoint); + DrawChartTitles(drawingContext, plot); + } - if (currentPoint is not null) - { - var point = ToScreen(currentPoint, plot, xMax, yMax); - drawingContext.DrawEllipse(_pointBrush, _pointPen, point, 5.5, 5.5); - } + private void DrawPlot(DrawingContext drawingContext, double width, double height, double xMax, double yMax, IReadOnlyList samples) + { + var plot = CreateSplitPlotRect(width, height); + var currentPoint = ReadPoints(CurrentPointSamples).LastOrDefault(); + + DrawPlotGridAndXAxis(drawingContext, plot, xMax, yMax); + DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(PeakLineSamples), _peakPen); + DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(AverageLineSamples), _averagePen); + DrawCurve(drawingContext, plot, xMax, yMax, samples); + DrawCurrentPoint(drawingContext, plot, xMax, yMax, currentPoint); + } + + private void DrawLeftAxis(DrawingContext drawingContext, double width, double height, double yMax) + { + var plot = CreateAxisPlotRect(width, height); + DrawYAxisLabels(drawingContext, plot, yMax, leftAxis: true, labelX: width - 10); + DrawText(drawingContext, "Friction force [N]", Math.Max(8, width - 116), plot.Top - 25, 12, _titleBrush); + drawingContext.DrawLine(_axisPen, new Point(width - 0.5, plot.Top), new Point(width - 0.5, plot.Bottom)); + } + + private void DrawRightAxis(DrawingContext drawingContext, double width, double height, double yMax) + { + var plot = CreateAxisPlotRect(width, height); + DrawYAxisLabels(drawingContext, plot, yMax, leftAxis: false, labelX: 10); + DrawText(drawingContext, "Coefficient of friction", 10, plot.Top - 25, 11, _titleBrush); + drawingContext.DrawLine(_axisPen, new Point(0.5, plot.Top), new Point(0.5, plot.Bottom)); } private static void OnCollectionSourceChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e) @@ -171,7 +248,7 @@ public sealed class RealtimeForceChart : FrameworkElement InvalidateVisual(); } - private void DrawGridAndAxes(DrawingContext drawingContext, Rect plot, double xMax, double yMax) + private void DrawPlotGridAndXAxis(DrawingContext drawingContext, Rect plot, double xMax, double yMax) { drawingContext.DrawRectangle(null, _axisPen, plot); @@ -203,7 +280,6 @@ public sealed class RealtimeForceChart : FrameworkElement const int yDivisions = 10; const int yMinorDivisions = 3; - var normalForceN = ResolveNormalForceN(); for (var index = 0; index <= yDivisions; index++) { var y = plot.Bottom - plot.Height * index / yDivisions; @@ -216,30 +292,78 @@ public sealed class RealtimeForceChart : FrameworkElement drawingContext.DrawLine(_minorGridPen, new Point(plot.Left, minorY), new Point(plot.Right, minorY)); } } - - DrawRightAlignedText( - drawingContext, - FormatForceNewtonLabel(yMax * index / yDivisions), - plot.Left - 10, - y - 8, - 11, - _labelBrush, - 4); - DrawText( - drawingContext, - FormatCoefficientLabel(yMax * index / yDivisions, normalForceN), - plot.Right + 8, - y - 8, - 11, - _labelBrush); } var xAxisTitleY = Math.Min(plot.Bottom + 30, ActualHeight - 18); DrawText(drawingContext, "Time [sec]", plot.Right - 58, xAxisTitleY, 12, _titleBrush); + } + + private void DrawYAxisLabels(DrawingContext drawingContext, Rect plot, double yMax, bool leftAxis, double labelX) + { + const int yDivisions = 10; + var normalForceN = ResolveNormalForceN(); + for (var index = 0; index <= yDivisions; index++) + { + var y = plot.Bottom - plot.Height * index / yDivisions; + var value = yMax * index / yDivisions; + if (leftAxis) + { + DrawRightAlignedText( + drawingContext, + FormatForceNewtonLabel(value), + labelX, + y - 8, + 11, + _labelBrush, + 4); + continue; + } + + DrawText( + drawingContext, + FormatCoefficientLabel(value, normalForceN), + labelX, + y - 8, + 11, + _labelBrush); + } + } + + private void DrawChartTitles(DrawingContext drawingContext, Rect plot) + { DrawText(drawingContext, "Friction force [N]", plot.Left, plot.Top - 25, 12, _titleBrush); DrawText(drawingContext, "Coefficient of friction", plot.Right - 8, plot.Top - 25, 11, _titleBrush); } + private void DrawCurrentPoint(DrawingContext drawingContext, Rect plot, double xMax, double yMax, ChartPoint? currentPoint) + { + if (currentPoint is null) + { + return; + } + + var point = ToScreen(currentPoint, plot, xMax, yMax); + drawingContext.DrawEllipse(_pointBrush, _pointPen, point, 5.5, 5.5); + } + + private static Rect CreateSplitPlotRect(double width, double height) + { + return new Rect( + 0, + PlotTop, + Math.Max(1, width), + Math.Max(1, height - PlotTop - PlotBottomMargin)); + } + + private static Rect CreateAxisPlotRect(double width, double height) + { + return new Rect( + 0, + PlotTop, + Math.Max(1, width), + Math.Max(1, height - PlotTop - PlotBottomMargin)); + } + private void DrawReferenceLine(DrawingContext drawingContext, Rect plot, double xMax, double yMax, double? value, Pen pen) { if (value is null || value <= 0 || !IsFinite(value.Value)) diff --git a/COFTester/ViewModels/MainViewModel.cs b/COFTester/ViewModels/MainViewModel.cs index f5bc008..669d9cc 100644 --- a/COFTester/ViewModels/MainViewModel.cs +++ b/COFTester/ViewModels/MainViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -58,8 +59,11 @@ public sealed class MainViewModel : ObservableObject, IDisposable private static readonly TimeSpan RealtimeChartRefreshInterval = TimeSpan.FromMilliseconds(250); private const double RealtimeChartTimeStepSeconds = 0.05; private const double RealtimeChartXAxisPaddingSeconds = 1; - private const double RealtimeChartMinimumCanvasWidth = 900; + private const double RealtimeChartMinimumCanvasWidth = 1; private const double RealtimeChartPixelsPerSecond = 18; + private const double RealtimeChartMinimumZoomScale = 0.000001; + private const double RealtimeChartMaximumZoomScale = 4; + private const double RealtimeChartZoomStep = 1.25; private const double MinimumEmptyChartXAxisMaxLimit = 1; private const double EmptyChartYAxisMaxLimit = 0.5; private const double EmptyChartPointForceN = 0.001; @@ -83,6 +87,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable private readonly RelayCommand _completeCalibrationCommand; private readonly RelayCommand _exportReportCommand; private readonly RelayCommand _clearHistoryCommand; + private readonly RelayCommand _realtimeChartZoomInCommand; + private readonly RelayCommand _realtimeChartZoomOutCommand; + private readonly RelayCommand _realtimeChartResetZoomCommand; private readonly TestDataRepository _dataRepository; private readonly RunExportService _runExportService; private readonly ObservableCollection _forceSamples = []; @@ -155,6 +162,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable private int _nextReciprocatingRecordIndex = 1; private double _lastForceXAxisMaxLimit; private double _lastForceYAxisMaxLimit; + private double _realtimeChartZoomScale = 1; public MainViewModel() { @@ -189,6 +197,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable _completeCalibrationCommand = new RelayCommand(CompleteCalibration, () => SelectedCalibrationItem is not null); _exportReportCommand = new RelayCommand(ExportHistoricalComparisonReport, CanExportHistoricalReport); _clearHistoryCommand = new RelayCommand(ClearHistoryData, CanClearHistoryStable); + _realtimeChartZoomInCommand = new RelayCommand(ZoomInRealtimeChart, () => _realtimeChartZoomScale < RealtimeChartMaximumZoomScale); + _realtimeChartZoomOutCommand = new RelayCommand(ZoomOutRealtimeChart); + _realtimeChartResetZoomCommand = new RelayCommand(ResetRealtimeChartZoom, () => Math.Abs(_realtimeChartZoomScale - 1) > 0.001); _timer = new DispatcherTimer { @@ -312,10 +323,14 @@ public sealed class MainViewModel : ObservableObject, IDisposable public double RealtimeChartXMax => _lastForceXAxisMaxLimit; - public double RealtimeChartCanvasWidth => Math.Max(RealtimeChartMinimumCanvasWidth, _lastForceXAxisMaxLimit * RealtimeChartPixelsPerSecond); + public double RealtimeChartCanvasWidth => Math.Max( + RealtimeChartMinimumCanvasWidth, + _lastForceXAxisMaxLimit * RealtimeChartPixelsPerSecond * _realtimeChartZoomScale); public double RealtimeChartYMax => _lastForceYAxisMaxLimit; + public string RealtimeChartZoomText => FormatRealtimeChartZoomText(_realtimeChartZoomScale); + public RelayCommand StartCommand => _startCommand; public RelayCommand PauseCommand => _pauseCommand; @@ -342,6 +357,26 @@ public sealed class MainViewModel : ObservableObject, IDisposable public RelayCommand ClearHistoryCommand => _clearHistoryCommand; + public RelayCommand RealtimeChartZoomInCommand => _realtimeChartZoomInCommand; + + public RelayCommand RealtimeChartZoomOutCommand => _realtimeChartZoomOutCommand; + + public RelayCommand RealtimeChartResetZoomCommand => _realtimeChartResetZoomCommand; + + public void ZoomRealtimeChartFromWheel(int wheelDelta) + { + if (wheelDelta > 0) + { + ZoomInRealtimeChart(); + return; + } + + if (wheelDelta < 0) + { + ZoomOutRealtimeChart(); + } + } + public void BeginTableMotion(string direction) { if (!TryGetTableMotionCoil(direction, out var actionName, out var coilAddress)) @@ -1463,6 +1498,49 @@ public sealed class MainViewModel : ObservableObject, IDisposable ]; } + private void ZoomInRealtimeChart() + { + SetRealtimeChartZoom(_realtimeChartZoomScale * RealtimeChartZoomStep); + } + + private void ZoomOutRealtimeChart() + { + SetRealtimeChartZoom(_realtimeChartZoomScale / RealtimeChartZoomStep); + } + + private void ResetRealtimeChartZoom() + { + SetRealtimeChartZoom(1); + } + + private void SetRealtimeChartZoom(double zoomScale) + { + var nextZoomScale = Math.Clamp(zoomScale, RealtimeChartMinimumZoomScale, RealtimeChartMaximumZoomScale); + var tolerance = Math.Max(0.000000000001, Math.Abs(_realtimeChartZoomScale) * 0.000000001); + if (Math.Abs(nextZoomScale - _realtimeChartZoomScale) <= tolerance) + { + return; + } + + _realtimeChartZoomScale = nextZoomScale; + OnPropertyChanged(nameof(RealtimeChartCanvasWidth)); + OnPropertyChanged(nameof(RealtimeChartZoomText)); + _realtimeChartZoomInCommand.RaiseCanExecuteChanged(); + _realtimeChartZoomOutCommand.RaiseCanExecuteChanged(); + _realtimeChartResetZoomCommand.RaiseCanExecuteChanged(); + NotifyRealtimeChartChanged(); + } + + private static string FormatRealtimeChartZoomText(double zoomScale) + { + return zoomScale switch + { + >= 0.1 => zoomScale.ToString("P0", CultureInfo.CurrentCulture), + >= 0.01 => zoomScale.ToString("P1", CultureInfo.CurrentCulture), + _ => zoomScale.ToString("P2", CultureInfo.CurrentCulture) + }; + } + private void UpdateReferenceLine(ObservableCollection target, double y) { if (y <= 0)