555 lines
19 KiB
C#
555 lines
19 KiB
C#
using System.Collections;
|
|
using System.Collections.Specialized;
|
|
using System.Globalization;
|
|
using System.Windows;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
|
|
namespace DentistryHandpieces;
|
|
|
|
public readonly record struct TorqueTrendViewState(
|
|
bool IsManual,
|
|
double MinTorque,
|
|
double MaxTorque,
|
|
double MinSpeed,
|
|
double MaxSpeed);
|
|
|
|
public sealed class TorqueTrendControl : FrameworkElement
|
|
{
|
|
private const string TorqueUnit = "mN.m";
|
|
private const string SpeedUnit = "r/min";
|
|
private const double SensorMinTorque = 0;
|
|
private const double SensorMaxTorque = 100;
|
|
private const double MinimumTorqueRange = 0.1;
|
|
private const double MinimumSpeedRange = 1;
|
|
private const double TorqueTrendBinSize = 0.1;
|
|
|
|
private bool _isManualView;
|
|
private double _viewMinTorque = SensorMinTorque;
|
|
private double _viewMaxTorque = SensorMaxTorque;
|
|
private double _viewMinSpeed;
|
|
private double _viewMaxSpeed = 1;
|
|
private Point? _lastDragPoint;
|
|
|
|
public TorqueTrendControl()
|
|
{
|
|
Focusable = true;
|
|
IsManipulationEnabled = true;
|
|
Cursor = Cursors.Cross;
|
|
}
|
|
|
|
public static readonly DependencyProperty SamplesProperty =
|
|
DependencyProperty.Register(
|
|
nameof(Samples),
|
|
typeof(IEnumerable),
|
|
typeof(TorqueTrendControl),
|
|
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnSamplesChanged));
|
|
|
|
public IEnumerable? Samples
|
|
{
|
|
get => (IEnumerable?)GetValue(SamplesProperty);
|
|
set => SetValue(SamplesProperty, value);
|
|
}
|
|
|
|
public TorqueTrendViewState GetViewState()
|
|
{
|
|
return new TorqueTrendViewState(
|
|
_isManualView,
|
|
_viewMinTorque,
|
|
_viewMaxTorque,
|
|
_viewMinSpeed,
|
|
_viewMaxSpeed);
|
|
}
|
|
|
|
public void ApplyViewState(TorqueTrendViewState state)
|
|
{
|
|
if (!state.IsManual)
|
|
{
|
|
ResetView();
|
|
return;
|
|
}
|
|
|
|
_isManualView = true;
|
|
_viewMinTorque = state.MinTorque;
|
|
_viewMaxTorque = state.MaxTorque;
|
|
_viewMinSpeed = state.MinSpeed;
|
|
_viewMaxSpeed = state.MaxSpeed;
|
|
ClampView();
|
|
InvalidateVisual();
|
|
}
|
|
|
|
public void ZoomIn()
|
|
{
|
|
Zoom(0.72, new Point(ActualWidth / 2, ActualHeight / 2));
|
|
}
|
|
|
|
public void ZoomOut()
|
|
{
|
|
Zoom(1.38, new Point(ActualWidth / 2, ActualHeight / 2));
|
|
}
|
|
|
|
public void ResetView()
|
|
{
|
|
_isManualView = false;
|
|
_viewMinTorque = SensorMinTorque;
|
|
_viewMaxTorque = SensorMaxTorque;
|
|
InvalidateVisual();
|
|
}
|
|
|
|
protected override Size MeasureOverride(Size availableSize)
|
|
{
|
|
double width = double.IsInfinity(availableSize.Width) ? 360 : availableSize.Width;
|
|
return new Size(width, 160);
|
|
}
|
|
|
|
protected override void OnRender(DrawingContext drawingContext)
|
|
{
|
|
base.OnRender(drawingContext);
|
|
|
|
var bounds = new Rect(0, 0, ActualWidth, ActualHeight);
|
|
drawingContext.DrawRoundedRectangle(
|
|
new SolidColorBrush(Color.FromRgb(248, 250, 252)),
|
|
new Pen(new SolidColorBrush(Color.FromRgb(213, 224, 235)), 1),
|
|
bounds,
|
|
6,
|
|
6);
|
|
|
|
if (ActualWidth < 120 || ActualHeight < 80)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Rect plot = GetPlotRect();
|
|
DrawGrid(drawingContext, plot);
|
|
|
|
List<TorqueSamplePayload> samples = ReadSamples();
|
|
if (samples.Count == 0)
|
|
{
|
|
DrawText(drawingContext, "等待转速/扭矩数据", 15, Color.FromRgb(82, 97, 111), new Point(plot.Left + 8, plot.Top + 8));
|
|
DrawAxisTitles(drawingContext, plot);
|
|
return;
|
|
}
|
|
|
|
(double minTorque, double maxTorque, double minSpeed, double maxSpeed) = GetVisibleRanges(samples);
|
|
DrawAxisLabels(drawingContext, plot, minTorque, maxTorque, minSpeed, maxSpeed);
|
|
|
|
var points = samples
|
|
.Select(sample => ToPlotPoint(sample, plot, minTorque, maxTorque, minSpeed, maxSpeed))
|
|
.ToList();
|
|
List<TorqueTrendPoint> trendPoints = BuildTrendPoints(samples);
|
|
|
|
drawingContext.PushClip(new RectangleGeometry(plot));
|
|
DrawSamplePoints(drawingContext, points);
|
|
DrawTrendLine(
|
|
drawingContext,
|
|
trendPoints
|
|
.Select(point => ToPlotPoint(point.Torque, point.Speed, plot, minTorque, maxTorque, minSpeed, maxSpeed))
|
|
.ToList());
|
|
DrawCurrentPoint(drawingContext, points[^1]);
|
|
drawingContext.Pop();
|
|
|
|
TorqueSamplePayload current = samples[^1];
|
|
DrawText(
|
|
drawingContext,
|
|
$"当前 {current.SpeedRpm:0} {SpeedUnit} / {current.TorqueMilliNewtonMeters:0.##} {TorqueUnit}",
|
|
12,
|
|
Color.FromRgb(15, 118, 110),
|
|
new Point(plot.Left + 6, plot.Top + 5));
|
|
DrawLegend(drawingContext, plot, trendPoints.Count >= 2);
|
|
if (trendPoints.Count < 2)
|
|
{
|
|
DrawText(
|
|
drawingContext,
|
|
"扭矩变化小于传感器精度,暂无有效趋势线",
|
|
11,
|
|
Color.FromRgb(180, 83, 9),
|
|
new Point(plot.Left + 6, plot.Bottom - 38));
|
|
}
|
|
}
|
|
|
|
protected override void OnMouseWheel(MouseWheelEventArgs e)
|
|
{
|
|
base.OnMouseWheel(e);
|
|
Zoom(e.Delta > 0 ? 0.82 : 1.22, e.GetPosition(this));
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
|
|
{
|
|
base.OnMouseLeftButtonDown(e);
|
|
Focus();
|
|
_lastDragPoint = e.GetPosition(this);
|
|
CaptureMouse();
|
|
Cursor = Cursors.Hand;
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnMouseMove(MouseEventArgs e)
|
|
{
|
|
base.OnMouseMove(e);
|
|
if (_lastDragPoint is not Point previous || e.LeftButton != MouseButtonState.Pressed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Point current = e.GetPosition(this);
|
|
Pan(current.X - previous.X, current.Y - previous.Y);
|
|
_lastDragPoint = current;
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
|
|
{
|
|
base.OnMouseLeftButtonUp(e);
|
|
EndMouseDrag();
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnLostMouseCapture(MouseEventArgs e)
|
|
{
|
|
base.OnLostMouseCapture(e);
|
|
EndMouseDrag();
|
|
}
|
|
|
|
protected override void OnManipulationStarting(ManipulationStartingEventArgs e)
|
|
{
|
|
base.OnManipulationStarting(e);
|
|
e.ManipulationContainer = this;
|
|
e.Mode = ManipulationModes.Scale | ManipulationModes.Translate;
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
|
|
{
|
|
base.OnManipulationDelta(e);
|
|
Vector translation = e.DeltaManipulation.Translation;
|
|
if (Math.Abs(translation.X) > 0.01 || Math.Abs(translation.Y) > 0.01)
|
|
{
|
|
Pan(translation.X, translation.Y);
|
|
}
|
|
|
|
double scale = Math.Sqrt(e.DeltaManipulation.Scale.X * e.DeltaManipulation.Scale.Y);
|
|
if (double.IsFinite(scale) && Math.Abs(scale - 1) > 0.005)
|
|
{
|
|
Zoom(1 / scale, e.ManipulationOrigin);
|
|
}
|
|
|
|
e.Handled = true;
|
|
}
|
|
|
|
private static void OnSamplesChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
|
|
{
|
|
var control = (TorqueTrendControl)dependencyObject;
|
|
if (e.OldValue is INotifyCollectionChanged oldCollection)
|
|
{
|
|
oldCollection.CollectionChanged -= control.Samples_CollectionChanged;
|
|
}
|
|
|
|
if (e.NewValue is INotifyCollectionChanged newCollection)
|
|
{
|
|
newCollection.CollectionChanged += control.Samples_CollectionChanged;
|
|
}
|
|
|
|
control.InvalidateVisual();
|
|
}
|
|
|
|
private void Samples_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
InvalidateVisual();
|
|
}
|
|
|
|
private Rect GetPlotRect()
|
|
{
|
|
return new Rect(52, 14, Math.Max(1, ActualWidth - 70), Math.Max(1, ActualHeight - 42));
|
|
}
|
|
|
|
private static void DrawGrid(DrawingContext drawingContext, Rect plot)
|
|
{
|
|
var gridPen = new Pen(new SolidColorBrush(Color.FromRgb(219, 229, 238)), 1);
|
|
for (int i = 0; i <= 4; i++)
|
|
{
|
|
double y = plot.Top + plot.Height * i / 4;
|
|
drawingContext.DrawLine(gridPen, new Point(plot.Left, y), new Point(plot.Right, y));
|
|
}
|
|
|
|
for (int i = 0; i <= 5; i++)
|
|
{
|
|
double x = plot.Left + plot.Width * i / 5;
|
|
drawingContext.DrawLine(gridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
|
|
}
|
|
}
|
|
|
|
private (double MinTorque, double MaxTorque, double MinSpeed, double MaxSpeed) GetVisibleRanges(List<TorqueSamplePayload> samples)
|
|
{
|
|
if (!_isManualView)
|
|
{
|
|
(_viewMinSpeed, _viewMaxSpeed) = GetAutoSpeedRange(samples);
|
|
}
|
|
|
|
return (_viewMinTorque, _viewMaxTorque, _viewMinSpeed, _viewMaxSpeed);
|
|
}
|
|
|
|
private static (double MinSpeed, double MaxSpeed) GetAutoSpeedRange(List<TorqueSamplePayload> samples)
|
|
{
|
|
double minSpeed = samples.Min(sample => sample.SpeedRpm);
|
|
double maxSpeed = samples.Max(sample => sample.SpeedRpm);
|
|
double range = maxSpeed - minSpeed;
|
|
double padding = range < MinimumSpeedRange
|
|
? Math.Max(Math.Abs(minSpeed) * 0.05, MinimumSpeedRange)
|
|
: range * 0.08;
|
|
return (minSpeed - padding, maxSpeed + padding);
|
|
}
|
|
|
|
private static Point ToPlotPoint(
|
|
TorqueSamplePayload sample,
|
|
Rect plot,
|
|
double minTorque,
|
|
double maxTorque,
|
|
double minSpeed,
|
|
double maxSpeed)
|
|
{
|
|
double x = plot.Left + plot.Width * (sample.TorqueMilliNewtonMeters - minTorque) / (maxTorque - minTorque);
|
|
double y = plot.Bottom - (sample.SpeedRpm - minSpeed) / (maxSpeed - minSpeed) * plot.Height;
|
|
return new Point(x, y);
|
|
}
|
|
|
|
private static Point ToPlotPoint(
|
|
double torque,
|
|
double speed,
|
|
Rect plot,
|
|
double minTorque,
|
|
double maxTorque,
|
|
double minSpeed,
|
|
double maxSpeed)
|
|
{
|
|
double x = plot.Left + plot.Width * (torque - minTorque) / (maxTorque - minTorque);
|
|
double y = plot.Bottom - (speed - minSpeed) / (maxSpeed - minSpeed) * plot.Height;
|
|
return new Point(x, y);
|
|
}
|
|
|
|
private static List<TorqueTrendPoint> BuildTrendPoints(IReadOnlyList<TorqueSamplePayload> samples)
|
|
{
|
|
return samples
|
|
.Where(sample => sample.TorqueMilliNewtonMeters >= SensorMinTorque
|
|
&& sample.TorqueMilliNewtonMeters <= SensorMaxTorque)
|
|
.GroupBy(sample => Math.Floor(sample.TorqueMilliNewtonMeters / TorqueTrendBinSize))
|
|
.Select(group => new TorqueTrendPoint(
|
|
group.Average(sample => sample.TorqueMilliNewtonMeters),
|
|
group.Average(sample => sample.SpeedRpm)))
|
|
.OrderBy(point => point.Torque)
|
|
.ToList();
|
|
}
|
|
|
|
private static void DrawTrendLine(DrawingContext drawingContext, IReadOnlyList<Point> points)
|
|
{
|
|
if (points.Count < 2)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var geometry = new StreamGeometry();
|
|
using (StreamGeometryContext context = geometry.Open())
|
|
{
|
|
context.BeginFigure(points[0], false, false);
|
|
for (int i = 1; i < points.Count; i++)
|
|
{
|
|
Point previous = points[i - 1];
|
|
Point current = points[i];
|
|
double deltaX = current.X - previous.X;
|
|
var control1 = new Point(previous.X + deltaX / 3, previous.Y);
|
|
var control2 = new Point(current.X - deltaX / 3, current.Y);
|
|
context.BezierTo(control1, control2, current, true, false);
|
|
}
|
|
}
|
|
|
|
geometry.Freeze();
|
|
var lineBrush = new SolidColorBrush(Color.FromRgb(15, 118, 110));
|
|
drawingContext.DrawGeometry(null, new Pen(lineBrush, 2.4), geometry);
|
|
}
|
|
|
|
private static void DrawSamplePoints(DrawingContext drawingContext, IReadOnlyList<Point> points)
|
|
{
|
|
var pointBrush = new SolidColorBrush(Color.FromArgb(175, 29, 78, 216));
|
|
Point? lastDrawn = null;
|
|
foreach (Point point in points)
|
|
{
|
|
if (lastDrawn is Point previous
|
|
&& Math.Abs(point.X - previous.X) < 1.5
|
|
&& Math.Abs(point.Y - previous.Y) < 1.5)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
drawingContext.DrawEllipse(pointBrush, null, point, 2.2, 2.2);
|
|
lastDrawn = point;
|
|
}
|
|
}
|
|
|
|
private static void DrawCurrentPoint(DrawingContext drawingContext, Point point)
|
|
{
|
|
var brush = new SolidColorBrush(Color.FromRgb(15, 118, 110));
|
|
drawingContext.DrawEllipse(brush, new Pen(Brushes.White, 1.5), point, 5, 5);
|
|
}
|
|
|
|
private void DrawLegend(DrawingContext drawingContext, Rect plot, bool hasTrend)
|
|
{
|
|
double y = plot.Bottom + 5;
|
|
var sampleBrush = new SolidColorBrush(Color.FromArgb(175, 29, 78, 216));
|
|
drawingContext.DrawEllipse(sampleBrush, null, new Point(plot.Left + 116, y + 7), 2.5, 2.5);
|
|
DrawText(drawingContext, "原始采样点", 10, Color.FromRgb(82, 97, 111), new Point(plot.Left + 122, y));
|
|
|
|
if (!hasTrend)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var trendPen = new Pen(new SolidColorBrush(Color.FromRgb(15, 118, 110)), 2.4);
|
|
drawingContext.DrawLine(trendPen, new Point(plot.Left + 194, y + 7), new Point(plot.Left + 214, y + 7));
|
|
DrawText(drawingContext, "显示趋势线", 10, Color.FromRgb(82, 97, 111), new Point(plot.Left + 220, y));
|
|
}
|
|
|
|
private void DrawAxisLabels(
|
|
DrawingContext drawingContext,
|
|
Rect plot,
|
|
double minTorque,
|
|
double maxTorque,
|
|
double minSpeed,
|
|
double maxSpeed)
|
|
{
|
|
DrawText(drawingContext, maxSpeed.ToString("0", CultureInfo.InvariantCulture), 11, Color.FromRgb(82, 97, 111), new Point(5, plot.Top - 2));
|
|
DrawText(drawingContext, minSpeed.ToString("0", CultureInfo.InvariantCulture), 11, Color.FromRgb(82, 97, 111), new Point(5, plot.Bottom - 14));
|
|
DrawText(drawingContext, minTorque.ToString("0.##", CultureInfo.InvariantCulture), 11, Color.FromRgb(82, 97, 111), new Point(plot.Left, plot.Bottom + 5));
|
|
DrawText(drawingContext, maxTorque.ToString("0.##", CultureInfo.InvariantCulture), 11, Color.FromRgb(82, 97, 111), new Point(plot.Right - 34, plot.Bottom + 5));
|
|
DrawAxisTitles(drawingContext, plot);
|
|
}
|
|
|
|
private void DrawAxisTitles(DrawingContext drawingContext, Rect plot)
|
|
{
|
|
DrawText(drawingContext, $"转速 ({SpeedUnit})", 11, Color.FromRgb(82, 97, 111), new Point(plot.Left + 4, plot.Top + 22));
|
|
DrawText(drawingContext, $"扭矩 ({TorqueUnit})", 11, Color.FromRgb(82, 97, 111), new Point(plot.Right - 82, plot.Bottom - 18));
|
|
}
|
|
|
|
private void Zoom(double factor, Point origin)
|
|
{
|
|
Rect plot = GetPlotRect();
|
|
if (plot.Width <= 1 || plot.Height <= 1 || !double.IsFinite(factor) || factor <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureManualView();
|
|
double originX = Math.Clamp((origin.X - plot.Left) / plot.Width, 0, 1);
|
|
double originY = Math.Clamp((plot.Bottom - origin.Y) / plot.Height, 0, 1);
|
|
double torqueAtOrigin = _viewMinTorque + (_viewMaxTorque - _viewMinTorque) * originX;
|
|
double speedAtOrigin = _viewMinSpeed + (_viewMaxSpeed - _viewMinSpeed) * originY;
|
|
double torqueRange = Math.Max(MinimumTorqueRange, (_viewMaxTorque - _viewMinTorque) * factor);
|
|
double speedRange = Math.Max(MinimumSpeedRange, (_viewMaxSpeed - _viewMinSpeed) * factor);
|
|
|
|
_viewMinTorque = torqueAtOrigin - torqueRange * originX;
|
|
_viewMaxTorque = _viewMinTorque + torqueRange;
|
|
_viewMinSpeed = speedAtOrigin - speedRange * originY;
|
|
_viewMaxSpeed = _viewMinSpeed + speedRange;
|
|
ClampView();
|
|
InvalidateVisual();
|
|
}
|
|
|
|
private void Pan(double deltaX, double deltaY)
|
|
{
|
|
Rect plot = GetPlotRect();
|
|
if (plot.Width <= 1 || plot.Height <= 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureManualView();
|
|
double torqueShift = -deltaX / plot.Width * (_viewMaxTorque - _viewMinTorque);
|
|
double speedShift = deltaY / plot.Height * (_viewMaxSpeed - _viewMinSpeed);
|
|
_viewMinTorque += torqueShift;
|
|
_viewMaxTorque += torqueShift;
|
|
_viewMinSpeed += speedShift;
|
|
_viewMaxSpeed += speedShift;
|
|
ClampView();
|
|
InvalidateVisual();
|
|
}
|
|
|
|
private void EnsureManualView()
|
|
{
|
|
if (_isManualView)
|
|
{
|
|
return;
|
|
}
|
|
|
|
List<TorqueSamplePayload> samples = ReadSamples();
|
|
(_viewMinSpeed, _viewMaxSpeed) = samples.Count == 0 ? (0, 1) : GetAutoSpeedRange(samples);
|
|
_isManualView = true;
|
|
}
|
|
|
|
private void ClampView()
|
|
{
|
|
double torqueRange = Math.Clamp(_viewMaxTorque - _viewMinTorque, MinimumTorqueRange, SensorMaxTorque - SensorMinTorque);
|
|
_viewMinTorque = Math.Clamp(_viewMinTorque, SensorMinTorque, SensorMaxTorque - torqueRange);
|
|
_viewMaxTorque = _viewMinTorque + torqueRange;
|
|
|
|
if (!double.IsFinite(_viewMinSpeed)
|
|
|| !double.IsFinite(_viewMaxSpeed)
|
|
|| _viewMaxSpeed - _viewMinSpeed < MinimumSpeedRange)
|
|
{
|
|
_viewMinSpeed = 0;
|
|
_viewMaxSpeed = MinimumSpeedRange;
|
|
}
|
|
}
|
|
|
|
private void EndMouseDrag()
|
|
{
|
|
_lastDragPoint = null;
|
|
if (IsMouseCaptured)
|
|
{
|
|
ReleaseMouseCapture();
|
|
}
|
|
|
|
Cursor = Cursors.Cross;
|
|
}
|
|
|
|
private List<TorqueSamplePayload> ReadSamples()
|
|
{
|
|
if (Samples is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var values = new List<TorqueSamplePayload>();
|
|
foreach (object? sample in Samples)
|
|
{
|
|
if (sample is TorqueSamplePayload torqueSample
|
|
&& double.IsFinite(torqueSample.ElapsedSeconds)
|
|
&& double.IsFinite(torqueSample.SpeedRpm)
|
|
&& double.IsFinite(torqueSample.TorqueMilliNewtonMeters))
|
|
{
|
|
values.Add(torqueSample);
|
|
}
|
|
else if (sample is double value && double.IsFinite(value))
|
|
{
|
|
values.Add(new TorqueSamplePayload { ElapsedSeconds = values.Count, SpeedRpm = values.Count, TorqueMilliNewtonMeters = value });
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
private void DrawText(DrawingContext drawingContext, string text, double size, Color color, Point origin)
|
|
{
|
|
var formattedText = new FormattedText(
|
|
text,
|
|
CultureInfo.CurrentUICulture,
|
|
FlowDirection.LeftToRight,
|
|
new Typeface("Microsoft YaHei UI"),
|
|
size,
|
|
new SolidColorBrush(color),
|
|
VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
|
|
|
drawingContext.DrawText(formattedText, origin);
|
|
}
|
|
|
|
private readonly record struct TorqueTrendPoint(double Torque, double Speed);
|
|
}
|