428 lines
15 KiB
C#
428 lines
15 KiB
C#
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<ChartPoint> 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<double> 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<ChartPoint> ReadPoints(IEnumerable? source)
|
|
{
|
|
if (source is null)
|
|
{
|
|
return Array.Empty<ChartPoint>();
|
|
}
|
|
|
|
var points = new List<ChartPoint>();
|
|
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);
|
|
}
|