diff --git a/COFTester/Controls/RealtimeForceChart.cs b/COFTester/Controls/RealtimeForceChart.cs new file mode 100644 index 0000000..3048a7c --- /dev/null +++ b/COFTester/Controls/RealtimeForceChart.cs @@ -0,0 +1,427 @@ +using System.Collections; +using System.Collections.Specialized; +using System.Globalization; +using System.Windows; +using System.Windows.Media; +using LiveChartsCore.Defaults; + +namespace COFTester.Controls; + +public sealed class RealtimeForceChart : FrameworkElement +{ + public static readonly DependencyProperty SamplesProperty = DependencyProperty.Register( + nameof(Samples), + typeof(IEnumerable), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged)); + + public static readonly DependencyProperty PeakLineSamplesProperty = DependencyProperty.Register( + nameof(PeakLineSamples), + typeof(IEnumerable), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged)); + + public static readonly DependencyProperty AverageLineSamplesProperty = DependencyProperty.Register( + nameof(AverageLineSamples), + typeof(IEnumerable), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged)); + + public static readonly DependencyProperty CurrentPointSamplesProperty = DependencyProperty.Register( + nameof(CurrentPointSamples), + typeof(IEnumerable), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnCollectionSourceChanged)); + + public static readonly DependencyProperty XMaxProperty = DependencyProperty.Register( + nameof(XMax), + typeof(double), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(1d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty YMaxProperty = DependencyProperty.Register( + nameof(YMax), + typeof(double), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(0.5d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty TravelMmProperty = DependencyProperty.Register( + nameof(TravelMm), + typeof(double), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(1d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty SledMassGramsProperty = DependencyProperty.Register( + nameof(SledMassGrams), + typeof(double), + typeof(RealtimeForceChart), + new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); + + private const double NewtonToGramForce = 101.9716213; + private const double GravityMetersPerSecondSquared = 9.80665; + + private readonly Pen _axisPen = new(new SolidColorBrush(Color.FromRgb(54, 54, 54)), 1); + private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromRgb(170, 170, 170)), 0.9); + private readonly Pen _minorGridPen = new(new SolidColorBrush(Color.FromRgb(216, 216, 216)), 0.7); + private readonly Pen _curvePen = new(new SolidColorBrush(Color.FromRgb(18, 22, 25)), 1.7); + private readonly Pen _peakPen = new(new SolidColorBrush(Color.FromRgb(86, 86, 86)), 1) { DashStyle = DashStyles.Dash }; + private readonly Pen _averagePen = new(new SolidColorBrush(Color.FromRgb(120, 120, 120)), 1) { DashStyle = DashStyles.Dash }; + private readonly Brush _labelBrush = new SolidColorBrush(Color.FromRgb(63, 63, 63)); + private readonly Brush _titleBrush = new SolidColorBrush(Color.FromRgb(34, 34, 34)); + private readonly Brush _chartBackgroundBrush = new SolidColorBrush(Color.FromRgb(252, 253, 248)); + private readonly Pen _chartBorderPen = new(new SolidColorBrush(Color.FromRgb(110, 112, 104)), 1); + 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 IEnumerable? Samples + { + get => (IEnumerable?)GetValue(SamplesProperty); + set => SetValue(SamplesProperty, value); + } + + public IEnumerable? PeakLineSamples + { + get => (IEnumerable?)GetValue(PeakLineSamplesProperty); + set => SetValue(PeakLineSamplesProperty, value); + } + + public IEnumerable? AverageLineSamples + { + get => (IEnumerable?)GetValue(AverageLineSamplesProperty); + set => SetValue(AverageLineSamplesProperty, value); + } + + public IEnumerable? CurrentPointSamples + { + get => (IEnumerable?)GetValue(CurrentPointSamplesProperty); + set => SetValue(CurrentPointSamplesProperty, value); + } + + public double XMax + { + get => (double)GetValue(XMaxProperty); + set => SetValue(XMaxProperty, value); + } + + public double YMax + { + get => (double)GetValue(YMaxProperty); + set => SetValue(YMaxProperty, value); + } + + public double TravelMm + { + get => (double)GetValue(TravelMmProperty); + set => SetValue(TravelMmProperty, value); + } + + public double SledMassGrams + { + get => (double)GetValue(SledMassGramsProperty); + set => SetValue(SledMassGramsProperty, value); + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + + var width = ActualWidth; + var height = ActualHeight; + if (width < 120 || 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); + DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(PeakLineSamples), _peakPen); + DrawReferenceLine(drawingContext, plot, xMax, yMax, ReadLineValue(AverageLineSamples), _averagePen); + DrawCurve(drawingContext, plot, xMax, yMax, samples); + + if (currentPoint is not null) + { + var point = ToScreen(currentPoint, plot, xMax, yMax); + drawingContext.DrawEllipse(_pointBrush, _pointPen, point, 5.5, 5.5); + } + } + + private static void OnCollectionSourceChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e) + { + var chart = (RealtimeForceChart)dependencyObject; + if (e.OldValue is INotifyCollectionChanged oldCollection) + { + oldCollection.CollectionChanged -= chart.OnCollectionChanged; + } + + if (e.NewValue is INotifyCollectionChanged newCollection) + { + newCollection.CollectionChanged += chart.OnCollectionChanged; + } + + chart.InvalidateVisual(); + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + InvalidateVisual(); + } + + private void DrawGridAndAxes(DrawingContext drawingContext, Rect plot, double xMax, double yMax) + { + drawingContext.DrawRectangle(null, _axisPen, plot); + + var xDivisions = ResolveXAxisDivisions(plot.Width); + const int xMinorDivisions = 5; + for (var index = 0; index <= xDivisions; index++) + { + var x = plot.Left + plot.Width * index / xDivisions; + drawingContext.DrawLine(index == 0 ? _axisPen : _gridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + if (index < xDivisions) + { + for (var minorIndex = 1; minorIndex < xMinorDivisions; minorIndex++) + { + var minorX = x + plot.Width / xDivisions * minorIndex / xMinorDivisions; + drawingContext.DrawLine(_minorGridPen, new Point(minorX, plot.Top), new Point(minorX, plot.Bottom)); + } + } + + DrawCenteredText( + drawingContext, + (xMax * index / xDivisions).ToString("0.#", CultureInfo.InvariantCulture), + x, + plot.Bottom + 8, + 11, + _labelBrush, + plot.Left, + plot.Right); + } + + 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; + drawingContext.DrawLine(index == 0 ? _axisPen : _gridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + if (index < yDivisions) + { + for (var minorIndex = 1; minorIndex < yMinorDivisions; minorIndex++) + { + var minorY = y - plot.Height / yDivisions * minorIndex / yMinorDivisions; + drawingContext.DrawLine(_minorGridPen, new Point(plot.Left, minorY), new Point(plot.Right, minorY)); + } + } + + DrawRightAlignedText( + drawingContext, + FormatForceGramLabel(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); + DrawText(drawingContext, "Friction force [gf]", plot.Left, plot.Top - 25, 12, _titleBrush); + DrawText(drawingContext, "Coefficient of friction", plot.Right - 8, plot.Top - 25, 11, _titleBrush); + } + + private void DrawReferenceLine(DrawingContext drawingContext, Rect plot, double xMax, double yMax, double? value, Pen pen) + { + if (value is null || value <= 0 || !IsFinite(value.Value)) + { + return; + } + + var y = ToScreen(new ChartPoint(0, value.Value), plot, xMax, yMax).Y; + drawingContext.DrawLine(pen, new Point(plot.Left, y), new Point(plot.Right, y)); + } + + private void DrawCurve(DrawingContext drawingContext, Rect plot, double xMax, double yMax, IReadOnlyList samples) + { + if (samples.Count == 0) + { + return; + } + + if (samples.Count == 1) + { + drawingContext.DrawEllipse(_pointBrush, _pointPen, ToScreen(samples[0], plot, xMax, yMax), 4, 4); + return; + } + + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + context.BeginFigure(ToScreen(samples[0], plot, xMax, yMax), false, false); + for (var index = 1; index < samples.Count; index++) + { + context.LineTo(ToScreen(samples[index], plot, xMax, yMax), true, false); + } + } + + geometry.Freeze(); + drawingContext.DrawGeometry(null, _curvePen, geometry); + } + + private static int ResolveXAxisDivisions(double plotWidth) + { + if (plotWidth >= 700) + { + return 10; + } + + if (plotWidth >= 520) + { + return 8; + } + + if (plotWidth >= 360) + { + return 6; + } + + return 4; + } + + private void DrawCenteredText( + DrawingContext drawingContext, + string text, + double centerX, + double y, + double fontSize, + Brush brush, + double leftLimit, + double rightLimit) + { + var formattedText = CreateFormattedText(text, fontSize, brush); + var x = Math.Clamp(centerX - formattedText.Width / 2, leftLimit, Math.Max(leftLimit, rightLimit - formattedText.Width)); + drawingContext.DrawText(formattedText, new Point(x, y)); + } + + private void DrawText(DrawingContext drawingContext, string text, double x, double y, double fontSize, Brush brush) + { + drawingContext.DrawText(CreateFormattedText(text, fontSize, brush), new Point(x, y)); + } + + private void DrawRightAlignedText( + DrawingContext drawingContext, + string text, + double rightX, + double y, + double fontSize, + Brush brush, + double leftLimit) + { + var formattedText = CreateFormattedText(text, fontSize, brush); + var x = Math.Max(leftLimit, rightX - formattedText.Width); + drawingContext.DrawText(formattedText, new Point(x, y)); + } + + private FormattedText CreateFormattedText(string text, double fontSize, Brush brush) + { + return new FormattedText( + text, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface("Microsoft YaHei UI"), + fontSize, + brush, + VisualTreeHelper.GetDpi(this).PixelsPerDip); + } + + private static Point ToScreen(ChartPoint point, Rect plot, double xMax, double yMax) + { + var x = plot.Left + plot.Width * Math.Clamp(point.X / xMax, 0, 1); + var y = plot.Bottom - plot.Height * Math.Clamp(point.Y / yMax, 0, 1); + return new Point(x, y); + } + + private static double ResolveAxisMax(double configuredMax, double fallback, IEnumerable values) + { + var max = IsFinite(configuredMax) && configuredMax > 0 ? configuredMax : fallback; + foreach (var value in values) + { + if (IsFinite(value)) + { + max = Math.Max(max, value); + } + } + + return Math.Max(max, 0.001); + } + + private double ResolveNormalForceN() + { + return IsFinite(SledMassGrams) && SledMassGrams > 0 + ? SledMassGrams / 1000d * GravityMetersPerSecondSquared + : 0; + } + + private static string FormatForceGramLabel(double forceN) + { + var forceGf = forceN * NewtonToGramForce; + return forceGf >= 100 + ? forceGf.ToString("0", CultureInfo.InvariantCulture) + : forceGf.ToString("0.#", CultureInfo.InvariantCulture); + } + + private static string FormatCoefficientLabel(double forceN, double normalForceN) + { + return normalForceN > 0 + ? (forceN / normalForceN).ToString("0.000", CultureInfo.InvariantCulture) + : "0.000"; + } + + private static double? ReadLineValue(IEnumerable? source) + { + return ReadPoints(source).FirstOrDefault(point => point.Y > 0)?.Y; + } + + private static IReadOnlyList ReadPoints(IEnumerable? source) + { + if (source is null) + { + return Array.Empty(); + } + + var points = new List(); + foreach (var item in source) + { + if (item is not ObservablePoint point || + point.X is not { } x || + point.Y is not { } y || + !IsFinite(x) || + !IsFinite(y)) + { + continue; + } + + points.Add(new ChartPoint(x, Math.Max(0, y))); + } + + return points; + } + + private static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + private sealed record ChartPoint(double X, double Y); +} diff --git a/COFTester/MainWindow.xaml b/COFTester/MainWindow.xaml index 2fa8d2f..63e213b 100644 --- a/COFTester/MainWindow.xaml +++ b/COFTester/MainWindow.xaml @@ -1,8 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - @@ -273,781 +206,428 @@ - + - - + - + - - - - - - - + + + + + + + + + - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -