From 54a88e1f31bfc1facc93f30cb1a5f113e5c1d5af Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 1 Jun 2026 18:45:00 -0500 Subject: [PATCH] plot window refactor --- .../Utilities/SensorFormatterTests.cs | 23 + .../ViewModels/SimpleViewModelsTests.cs | 26 +- .../MainWindow.cs | 644 ++++++++++++++++-- .../PlotWindow.cs | 19 +- .../Utilities/SensorFormatter.cs | 36 +- .../ViewModels/MainWindowViewModel.cs | 194 +++++- .../ViewModels/PlotSeriesViewModel.cs | 34 +- 7 files changed, 899 insertions(+), 77 deletions(-) diff --git a/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs b/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs index ea78a72..0fda401 100644 --- a/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs +++ b/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs @@ -144,6 +144,29 @@ public class SensorFormatterTests Assert.Equal(25.5f, result); } + [Fact] + public void GetPlotValue_HistoricalTemperatureFahrenheit_ReturnsConvertedValue() + { + var mockSensor = new Mock(); + mockSensor.Setup(s => s.SensorType).Returns(SensorType.Temperature); + + var result = SensorFormatter.GetPlotValue(mockSensor.Object, 25.5f, TemperatureUnit.Fahrenheit); + + Assert.Equal(77.9, result.Value, 1); + } + + [Fact] + public void GetPlotUnit_TemperatureFahrenheit_ReturnsFahrenheitUnit() + { + Assert.Equal("\u00B0F", SensorFormatter.GetPlotUnit(SensorType.Temperature, TemperatureUnit.Fahrenheit)); + } + + [Fact] + public void GetPlotUnit_Load_ReturnsPercentUnit() + { + Assert.Equal("%", SensorFormatter.GetPlotUnit(SensorType.Load, TemperatureUnit.Celsius)); + } + // A mock interface combining ISensor and ICriticalSensorLimits for testing public interface ICriticalSensorMock : ISensor, ICriticalSensorLimits, ISensorLimits { diff --git a/LibreHardwareMonitor.Windows.WinUI.Tests/ViewModels/SimpleViewModelsTests.cs b/LibreHardwareMonitor.Windows.WinUI.Tests/ViewModels/SimpleViewModelsTests.cs index acf9098..57afbdc 100644 --- a/LibreHardwareMonitor.Windows.WinUI.Tests/ViewModels/SimpleViewModelsTests.cs +++ b/LibreHardwareMonitor.Windows.WinUI.Tests/ViewModels/SimpleViewModelsTests.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using LibreHardwareMonitor.Hardware; using Windows.UI; using LibreHardwareMonitor.Windows.WinUI.ViewModels; using Xunit; @@ -78,10 +79,14 @@ public class SimpleViewModelsTests public void PlotSeriesViewModel_Constructor_SetsProperties() { var color = Color.FromArgb(255, 255, 0, 0); - var vm = new PlotSeriesViewModel("sensor1", "Sensor Name", color); + var vm = new PlotSeriesViewModel("sensor1", "Hardware Name", "Sensor Name", SensorType.Temperature, "\u00B0C", color); Assert.Equal("sensor1", vm.SensorIdentifier); + Assert.Equal("Hardware Name", vm.HardwareName); Assert.Equal("Sensor Name", vm.Name); + Assert.Equal(SensorType.Temperature, vm.SensorType); + Assert.Equal("\u00B0C", vm.Unit); + Assert.Equal("Hardware Name Sensor Name", vm.Title); Assert.Equal(color, vm.Color); Assert.NotNull(vm.Points); Assert.Empty(vm.Points); @@ -92,10 +97,27 @@ public class SimpleViewModelsTests { var color1 = Color.FromArgb(255, 255, 0, 0); var color2 = Color.FromArgb(255, 0, 255, 0); - var vm = new PlotSeriesViewModel("sensor1", "Sensor Name", color1); + var vm = new PlotSeriesViewModel("sensor1", "Hardware Name", "Sensor Name", SensorType.Temperature, "\u00B0C", color1); vm.Color = color2; Assert.Equal(color2, vm.Color); } + + [Fact] + public void PlotSeriesViewModel_ReplacePoints_ReplacesExistingPoints() + { + var color = Color.FromArgb(255, 255, 0, 0); + var vm = new PlotSeriesViewModel("sensor1", "Hardware Name", "Sensor Name", SensorType.Temperature, "\u00B0C", color); + vm.Points.Add(new PlotPointViewModel(new DateTime(2023, 1, 1), 1)); + + vm.ReplacePoints([ + new PlotPointViewModel(new DateTime(2023, 1, 2), 2), + new PlotPointViewModel(new DateTime(2023, 1, 3), 3) + ]); + + Assert.Equal(2, vm.Points.Count); + Assert.Equal(2, vm.Points[0].Value); + Assert.Equal(3, vm.Points[1].Value); + } } diff --git a/LibreHardwareMonitor.Windows.WinUI/MainWindow.cs b/LibreHardwareMonitor.Windows.WinUI/MainWindow.cs index 4923833..fba7662 100644 --- a/LibreHardwareMonitor.Windows.WinUI/MainWindow.cs +++ b/LibreHardwareMonitor.Windows.WinUI/MainWindow.cs @@ -14,9 +14,11 @@ using System.Runtime.InteropServices; using System.Threading.Tasks; using LibreHardwareMonitor.Hardware; using LibreHardwareMonitor.Windows.WinUI.Services; +using LibreHardwareMonitor.Windows.WinUI.Utilities; using LibreHardwareMonitor.Windows.WinUI.ViewModels; using Microsoft.UI; using Microsoft.UI.Dispatching; +using Microsoft.UI.Input; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -37,6 +39,22 @@ namespace LibreHardwareMonitor.Windows.WinUI; public sealed class MainWindow : Window { private static readonly TimeSpan DeviceColumnWidthSettleDelay = TimeSpan.FromSeconds(5); + private static readonly (string Label, int Value)[] PlotTimeWindowOptions = + [ + ("Auto", 0), + ("5 min", 1), + ("10 min", 2), + ("20 min", 3), + ("30 min", 4), + ("45 min", 5), + ("1 h", 6), + ("1.5 h", 7), + ("2 h", 8), + ("3 h", 9), + ("6 h", 10), + ("12 h", 11), + ("24 h", 12) + ]; private const double DefaultSensorColumnWidth = 320; private const double MaximumSensorColumnWidth = 4096; @@ -44,6 +62,12 @@ public sealed class MainWindow : Window private const double SensorColumnPadding = 72; private const double SensorTreeIndentWidth = 20; private const string DeviceColumnWidthSetting = "winui.deviceColumnWidth"; + private const double PlotAxisLabelFontSize = 11; + private const double PlotBottomMargin = 28; + private const double PlotLeftMargin = 86; + private const double PlotPointMarkerRadius = 3; + private const double PlotRightMargin = 12; + private const double PlotTopMargin = 10; private const double ValueColumnPadding = 18; private const int MaxTextMeasurementCacheEntries = 4096; @@ -79,6 +103,7 @@ public sealed class MainWindow : Window private bool _startupCompleteRecorded; private bool _sensorTreeRebuildQueued; private bool _sensorColumnWidthUpdateQueued; + private double _plotValueZoomFactor = 1; public MainWindow() : this(null) { @@ -159,6 +184,11 @@ public sealed class MainWindow : Window or nameof(ViewModel.PlotGridRow)) UpdatePlotLayout(); + if (args.PropertyName is nameof(ViewModel.PlotStackedAxes) + or nameof(ViewModel.ShowPlotAxisLabels) + or nameof(ViewModel.PlotTimeWindowIndex)) + DrawPlot(); + if (args.PropertyName == nameof(ViewModel.ShowGadget)) UpdateGadgetVisibility(); @@ -169,6 +199,7 @@ public sealed class MainWindow : Window { _trayIconService.Update(); SyncGadgetSensors(); + ViewModel.RefreshPlotSeries(); } if (args.PropertyName == nameof(ViewModel.ShowHiddenSensors)) @@ -592,7 +623,9 @@ public sealed class MainWindow : Window MinHeight = 160, Background = (Brush)Application.Current.Resources["SystemControlBackgroundAltHighBrush"] }; + canvas.ContextFlyout = BuildPlotContextMenu(); canvas.SizeChanged += (_, _) => DrawPlot(); + canvas.PointerWheelChanged += PlotCanvas_PointerWheelChanged; Grid.SetRow(canvas, 1); pane.Children.Add(canvas); @@ -841,6 +874,9 @@ public sealed class MainWindow : Window if (_plotWindow == null) { _plotWindow = new PlotWindow(ViewModel.Settings); + _plotWindow.ApplyTheme(ViewModel.ThemeMode); + _plotWindow.PlotCanvas.ContextFlyout = BuildPlotContextMenu(); + _plotWindow.PlotCanvas.PointerWheelChanged += PlotCanvas_PointerWheelChanged; _plotWindow.PlotSizeChanged += (_, _) => DrawPlot(); _plotWindow.UserClosed += (_, _) => { @@ -964,6 +1000,8 @@ public sealed class MainWindow : Window CheckBox plotCheck = new() { MinWidth = 22 }; Bind(plotCheck, ToggleButton.IsCheckedProperty, item, nameof(SensorTreeItemViewModel.Plot), BindingMode.TwoWay); Bind(plotCheck, UIElement.VisibilityProperty, item, nameof(SensorTreeItemViewModel.PlotCheckVisibility)); + plotCheck.Checked += (_, _) => QueuePlotSeriesRefresh(); + plotCheck.Unchecked += (_, _) => QueuePlotSeriesRefresh(); sensorCell.Children.Add(plotCheck); FontIcon icon = new() { FontSize = 14 }; @@ -1003,6 +1041,7 @@ public sealed class MainWindow : Window ToggleMenuFlyoutItem plot = new() { Text = "Show in Plot", IsEnabled = item.Sensor != null }; Bind(plot, ToggleMenuFlyoutItem.IsCheckedProperty, item, nameof(SensorTreeItemViewModel.Plot), BindingMode.TwoWay); + plot.Click += (_, _) => QueuePlotSeriesRefresh(); flyout.Items.Add(plot); ToggleMenuFlyoutItem tray = new() @@ -1060,6 +1099,95 @@ public sealed class MainWindow : Window return flyout; } + private MenuFlyout BuildPlotContextMenu() + { + MenuFlyout flyout = new(); + flyout.Items.Add(CreateToggleSettingItem("Stacked Axes", () => ViewModel.PlotStackedAxes, value => ViewModel.PlotStackedAxes = value)); + flyout.Items.Add(CreateToggleSettingItem("Show Axes Labels", () => ViewModel.ShowPlotAxisLabels, value => ViewModel.ShowPlotAxisLabels = value)); + + MenuFlyoutSubItem timeAxis = new() { Text = "Time Axis" }; + timeAxis.Items.Add(CreateToggleSettingItem("Enable Zoom", () => ViewModel.PlotTimeAxisZoomEnabled, value => ViewModel.PlotTimeAxisZoomEnabled = value)); + timeAxis.Items.Add(new MenuFlyoutSeparator()); + foreach ((string label, int value) in PlotTimeWindowOptions) + { + ToggleMenuFlyoutItem item = new() + { + Text = label, + IsChecked = ViewModel.PlotTimeWindowIndex == value, + Tag = value + }; + item.Click += (_, _) => + { + ViewModel.PlotTimeWindowIndex = value; + foreach (ToggleMenuFlyoutItem sibling in timeAxis.Items.OfType()) + sibling.IsChecked = Equals(sibling.Tag, value); + }; + timeAxis.Items.Add(item); + } + + flyout.Items.Add(timeAxis); + + MenuFlyoutSubItem valueAxes = new() { Text = "Value Axes" }; + valueAxes.Items.Add(CreateToggleSettingItem("Enable Zoom", () => ViewModel.PlotValueAxesZoomEnabled, value => ViewModel.PlotValueAxesZoomEnabled = value)); + valueAxes.Items.Add(CreateMenuItem("Autoscale All", (_, _) => + { + _plotValueZoomFactor = 1; + DrawPlot(); + })); + flyout.Items.Add(valueAxes); + + return flyout; + } + + private void PlotCanvas_PointerWheelChanged(object sender, PointerRoutedEventArgs e) + { + if (sender is not Canvas canvas) + return; + + PointerPoint pointerPoint = e.GetCurrentPoint(canvas); + int wheelDelta = pointerPoint.Properties.MouseWheelDelta; + if (wheelDelta == 0) + return; + + PlotBounds bounds = GetPlotBounds(GetPlotCanvasWidth(canvas), GetPlotCanvasHeight(canvas)); + if (ViewModel.PlotValueAxesZoomEnabled && ShouldDrawPlotAxisLabels(bounds) && pointerPoint.Position.X <= bounds.Left) + { + ZoomPlotValueAxes(wheelDelta); + e.Handled = true; + return; + } + + if (ViewModel.PlotTimeAxisZoomEnabled) + { + ZoomPlotTimeAxis(wheelDelta); + e.Handled = true; + } + } + + private void ZoomPlotValueAxes(int wheelDelta) + { + _plotValueZoomFactor = Math.Clamp(_plotValueZoomFactor * (wheelDelta > 0 ? 0.8 : 1.25), 0.05, 20); + DrawPlot(); + } + + private void ZoomPlotTimeAxis(int wheelDelta) + { + int lastIndex = PlotTimeWindowOptions[^1].Value; + int index = ViewModel.PlotTimeWindowIndex; + if (wheelDelta > 0) + index = index == 0 ? lastIndex : Math.Max(1, index - 1); + else + index = index >= lastIndex ? 0 : index + 1; + + ViewModel.PlotTimeWindowIndex = index; + } + + private void QueuePlotSeriesRefresh() + { + if (!DispatcherQueue.TryEnqueue(ViewModel.RefreshPlotSeries)) + ViewModel.RefreshPlotSeries(); + } + private static MenuFlyoutSubItem BuildControlMenu(IControl control) { MenuFlyoutSubItem controlItem = new() { Text = "Control" }; @@ -1437,78 +1565,452 @@ public sealed class MainWindow : Window private void DrawPlot(Canvas canvas) { canvas.Children.Clear(); - if (!ViewModel.ShowPlot || ViewModel.PlotSeries.Count == 0 || canvas.ActualWidth <= 4 || canvas.ActualHeight <= 4) + canvas.Background = new SolidColorBrush(GetPlotBackgroundColor()); + if (!ViewModel.ShowPlot) return; - double minTime = double.MaxValue; - double maxTime = double.MinValue; - double minValue = double.MaxValue; - double maxValue = double.MinValue; + double width = GetPlotCanvasWidth(canvas); + double height = GetPlotCanvasHeight(canvas); + if (width <= 4 || height <= 4) + return; - foreach (PlotSeriesViewModel plotSeries in ViewModel.PlotSeries) + PlotBounds bounds = GetPlotBounds(width, height); + DrawPlotFrame(canvas, bounds); + + PlotSeriesViewModel[] plotSeriesWithPoints = ViewModel.PlotSeries + .Where(plotSeries => plotSeries.Points.Count > 0) + .ToArray(); + if (plotSeriesWithPoints.Length == 0) + { + DrawPlotMessage(canvas, bounds, ViewModel.PlotSeries.Count == 0 ? "No sensors selected for plot" : "Waiting for sensor samples..."); + return; + } + + DateTime minTimestamp = DateTime.MaxValue; + DateTime maxTimestamp = DateTime.MinValue; + + foreach (PlotSeriesViewModel plotSeries in plotSeriesWithPoints) { foreach (PlotPointViewModel point in plotSeries.Points) { - double time = point.Timestamp.ToOADate(); - minTime = Math.Min(minTime, time); - maxTime = Math.Max(maxTime, time); + minTimestamp = point.Timestamp < minTimestamp ? point.Timestamp : minTimestamp; + maxTimestamp = point.Timestamp > maxTimestamp ? point.Timestamp : maxTimestamp; + } + } + + if (minTimestamp == DateTime.MaxValue || maxTimestamp == DateTime.MinValue) + return; + + if (ViewModel.PlotTimeWindow.HasValue) + minTimestamp = maxTimestamp - ViewModel.PlotTimeWindow.Value; + + if (maxTimestamp <= minTimestamp) + maxTimestamp = minTimestamp.AddSeconds(1); + + List visibleSeries = []; + foreach (PlotSeriesViewModel plotSeries in plotSeriesWithPoints) + { + PlotPointViewModel[] points = plotSeries.Points + .Where(point => point.Timestamp >= minTimestamp && point.Timestamp <= maxTimestamp) + .OrderBy(point => point.Timestamp) + .ToArray(); + if (points.Length > 0) + visibleSeries.Add(new PlotSeriesSample(plotSeries, points)); + } + + if (visibleSeries.Count == 0) + { + DrawPlotMessage(canvas, bounds, "No samples in the selected time range"); + return; + } + + AdjustPlotTimeRangeForSparseSamples(visibleSeries, ref minTimestamp, ref maxTimestamp); + visibleSeries = visibleSeries + .Select(sample => new PlotSeriesSample( + sample.Series, + sample.Points.Where(point => point.Timestamp >= minTimestamp && point.Timestamp <= maxTimestamp).ToArray())) + .Where(sample => sample.Points.Count > 0) + .ToList(); + if (visibleSeries.Count == 0) + { + DrawPlotMessage(canvas, bounds, "Waiting for sensor samples..."); + return; + } + + DrawTimeAxis(canvas, bounds, minTimestamp, maxTimestamp); + + IGrouping[] groups = visibleSeries + .GroupBy(sample => sample.Series.SensorType) + .OrderBy(group => group.Key) + .ToArray(); + + double axisHeight = ViewModel.PlotStackedAxes ? bounds.Height / groups.Length : bounds.Height; + for (int i = 0; i < groups.Length; i++) + { + IGrouping group = groups[i]; + PlotSeriesSample[] samples = group.ToArray(); + GetValueRange(samples, out double minValue, out double maxValue); + ExpandPlotValueRange(ref minValue, ref maxValue); + ApplyPlotValueZoom(ref minValue, ref maxValue); + + PlotAxisLayout axis = new( + group.Key, + samples.First().Series.Unit, + ViewModel.PlotStackedAxes ? bounds.Top + axisHeight * i : bounds.Top, + axisHeight, + minValue, + maxValue); + + DrawValueAxis(canvas, bounds, axis, drawGrid: ViewModel.PlotStackedAxes || i == 0, titleIndex: i); + foreach (PlotSeriesSample sample in samples) + DrawPlotSeries(canvas, bounds, axis, minTimestamp, maxTimestamp, sample); + } + } + + private static double GetPlotCanvasWidth(Canvas canvas) + { + return Math.Max(canvas.ActualWidth, canvas.MinWidth); + } + + private static double GetPlotCanvasHeight(Canvas canvas) + { + return Math.Max(canvas.ActualHeight, canvas.MinHeight); + } + + private PlotBounds GetPlotBounds(double width, double height) + { + double left = ViewModel.ShowPlotAxisLabels ? PlotLeftMargin : 0; + double top = ViewModel.ShowPlotAxisLabels ? PlotTopMargin : 0; + double right = ViewModel.ShowPlotAxisLabels ? PlotRightMargin : 0; + double bottom = ViewModel.ShowPlotAxisLabels ? PlotBottomMargin : 0; + + if (width <= left + right + 32 || height <= top + bottom + 24) + return new PlotBounds(0, 0, Math.Max(1, width), Math.Max(1, height)); + + return new PlotBounds(left, top, Math.Max(1, width - left - right), Math.Max(1, height - top - bottom)); + } + + private void DrawPointMarker(Canvas canvas, global::Windows.Foundation.Point point, Brush fill) + { + Ellipse marker = new() + { + Width = PlotPointMarkerRadius * 2, + Height = PlotPointMarkerRadius * 2, + Fill = fill + }; + Canvas.SetLeft(marker, point.X - PlotPointMarkerRadius); + Canvas.SetTop(marker, point.Y - PlotPointMarkerRadius); + canvas.Children.Add(marker); + } + + private void DrawPlotFrame(Canvas canvas, PlotBounds bounds) + { + SolidColorBrush border = new(GetPlotBorderColor()); + DrawLine(canvas, bounds.Left, bounds.Top, bounds.Right, bounds.Top, border, 1); + DrawLine(canvas, bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom, border, 1); + DrawLine(canvas, bounds.Left, bounds.Top, bounds.Left, bounds.Bottom, border, 1); + DrawLine(canvas, bounds.Right, bounds.Top, bounds.Right, bounds.Bottom, border, 1); + } + + private void DrawValueAxis(Canvas canvas, PlotBounds bounds, PlotAxisLayout axis, bool drawGrid, int titleIndex) + { + SolidColorBrush gridBrush = new(GetPlotGridColor()); + SolidColorBrush textBrush = new(GetPlotTextColor()); + SolidColorBrush borderBrush = new(GetPlotBorderColor()); + bool drawLabels = ShouldDrawPlotAxisLabels(bounds); + + for (int i = 0; i <= 4; i++) + { + double y = axis.Top + axis.Height * i / 4; + if (drawGrid) + DrawLine(canvas, bounds.Left, y, bounds.Right, y, gridBrush, i is 0 or 4 ? 1 : 0.75); + + if (drawLabels) + { + double value = axis.MaxValue - (axis.MaxValue - axis.MinValue) * i / 4; + AddPlotLabel(canvas, FormatPlotAxisValue(value), 4, y - 8, textBrush, PlotAxisLabelFontSize); + } + } + + if (ViewModel.PlotStackedAxes) + DrawLine(canvas, bounds.Left, axis.Top, bounds.Right, axis.Top, borderBrush, 1); + + if (drawLabels) + { + string unit = string.IsNullOrWhiteSpace(axis.Unit) ? "" : $" ({axis.Unit})"; + AddPlotLabel(canvas, $"{SensorTypeDisplay.GetText(axis.SensorType)}{unit}", 4, axis.Top + 2 + (ViewModel.PlotStackedAxes ? 0 : titleIndex * 15), textBrush, PlotAxisLabelFontSize, 600); + } + } + + private void DrawTimeAxis(Canvas canvas, PlotBounds bounds, DateTime minTimestamp, DateTime maxTimestamp) + { + TimeSpan range = maxTimestamp - minTimestamp; + if (range <= TimeSpan.Zero) + range = TimeSpan.FromSeconds(1); + + SolidColorBrush gridBrush = new(GetPlotGridColor()); + SolidColorBrush textBrush = new(GetPlotTextColor()); + bool drawLabels = ShouldDrawPlotAxisLabels(bounds); + for (int i = 0; i <= 4; i++) + { + double x = bounds.Left + bounds.Width * i / 4; + DrawLine(canvas, x, bounds.Top, x, bounds.Bottom, gridBrush, i is 0 or 4 ? 1 : 0.75); + if (!drawLabels) + continue; + + TimeSpan age = TimeSpan.FromTicks((long)Math.Round(range.Ticks * (4 - i) / 4.0)); + TextBlock label = CreatePlotLabel(FormatPlotAge(age), textBrush, PlotAxisLabelFontSize); + label.Measure(new global::Windows.Foundation.Size(double.PositiveInfinity, double.PositiveInfinity)); + double maxLeft = Math.Max(0, bounds.Right - label.DesiredSize.Width); + Canvas.SetLeft(label, Math.Clamp(x - label.DesiredSize.Width / 2, 0, maxLeft)); + Canvas.SetTop(label, bounds.Bottom + 4); + canvas.Children.Add(label); + } + } + + private static void AdjustPlotTimeRangeForSparseSamples(IReadOnlyList visibleSeries, ref DateTime minTimestamp, ref DateTime maxTimestamp) + { + DateTime dataMin = DateTime.MaxValue; + DateTime dataMax = DateTime.MinValue; + foreach (PlotSeriesSample sample in visibleSeries) + { + foreach (PlotPointViewModel point in sample.Points) + { + dataMin = point.Timestamp < dataMin ? point.Timestamp : dataMin; + dataMax = point.Timestamp > dataMax ? point.Timestamp : dataMax; + } + } + + if (dataMin == DateTime.MaxValue || dataMax == DateTime.MinValue) + return; + + TimeSpan selectedRange = maxTimestamp - minTimestamp; + TimeSpan dataRange = dataMax - dataMin; + if (selectedRange <= TimeSpan.FromSeconds(30) || dataRange >= TimeSpan.FromSeconds(30)) + return; + + maxTimestamp = dataMax; + minTimestamp = maxTimestamp - TimeSpan.FromSeconds(30); + } + + private bool ShouldDrawPlotAxisLabels(PlotBounds bounds) + { + return ViewModel.ShowPlotAxisLabels && bounds.Left >= PlotLeftMargin && bounds.Height > 24 && bounds.Width > 32; + } + + private void DrawPlotSeries( + Canvas canvas, + PlotBounds bounds, + PlotAxisLayout axis, + DateTime minTimestamp, + DateTime maxTimestamp, + PlotSeriesSample sample) + { + long rangeTicks = Math.Max(1, (maxTimestamp - minTimestamp).Ticks); + SolidColorBrush stroke = new(GetVisiblePlotColor(sample.Series.Color)); + Polyline line = new() + { + Stroke = stroke, + StrokeThickness = ViewModel.PlotStrokeThickness, + StrokeLineJoin = PenLineJoin.Round + }; + + foreach (PlotPointViewModel point in sample.Points) + { + double x = bounds.Left + (point.Timestamp - minTimestamp).Ticks / (double)rangeTicks * bounds.Width; + double y = axis.Bottom - ((point.Value - axis.MinValue) / (axis.MaxValue - axis.MinValue) * axis.Height); + line.Points.Add(new global::Windows.Foundation.Point(x, y)); + } + + canvas.Children.Add(line); + if (line.Points.Count > 0) + DrawPointMarker(canvas, line.Points[^1], stroke); + } + + private void DrawPlotMessage(Canvas canvas, PlotBounds bounds, string message) + { + TextBlock label = CreatePlotLabel(message, new SolidColorBrush(GetPlotTextColor()), 13, 600); + label.Measure(new global::Windows.Foundation.Size(double.PositiveInfinity, double.PositiveInfinity)); + Canvas.SetLeft(label, bounds.Left + Math.Max(0, (bounds.Width - label.DesiredSize.Width) / 2)); + Canvas.SetTop(label, bounds.Top + Math.Max(0, (bounds.Height - label.DesiredSize.Height) / 2)); + canvas.Children.Add(label); + } + + private static void GetValueRange(IReadOnlyList samples, out double minValue, out double maxValue) + { + minValue = double.MaxValue; + maxValue = double.MinValue; + + foreach (PlotSeriesSample sample in samples) + { + foreach (PlotPointViewModel point in sample.Points) + { minValue = Math.Min(minValue, point.Value); maxValue = Math.Max(maxValue, point.Value); } } - - if (minTime == double.MaxValue || minValue == double.MaxValue) - return; - - if (Math.Abs(maxTime - minTime) < double.Epsilon) - maxTime = minTime + TimeSpan.FromSeconds(1).TotalDays; - if (Math.Abs(maxValue - minValue) < double.Epsilon) - { - maxValue += 1; - minValue -= 1; - } - - double width = canvas.ActualWidth; - double height = canvas.ActualHeight; - DrawAxis(canvas, width, height); - - foreach (PlotSeriesViewModel plotSeries in ViewModel.PlotSeries) - { - Polyline line = new() - { - Stroke = new SolidColorBrush(plotSeries.Color), - StrokeThickness = ViewModel.PlotStrokeThickness, - StrokeLineJoin = PenLineJoin.Round - }; - - foreach (PlotPointViewModel point in plotSeries.Points) - { - double x = (point.Timestamp.ToOADate() - minTime) / (maxTime - minTime) * width; - double y = height - ((point.Value - minValue) / (maxValue - minValue) * height); - line.Points.Add(new global::Windows.Foundation.Point(x, y)); - } - - canvas.Children.Add(line); - } } - private static void DrawAxis(Canvas canvas, double width, double height) + private static void ExpandPlotValueRange(ref double minValue, ref double maxValue) { - SolidColorBrush brush = new(Colors.Gray); - for (int i = 1; i < 4; i++) + if (!double.IsFinite(minValue) || !double.IsFinite(maxValue) || minValue == double.MaxValue || maxValue == double.MinValue) { - double y = height * i / 4; - canvas.Children.Add(new Line - { - X1 = 0, - X2 = width, - Y1 = y, - Y2 = y, - Stroke = brush, - StrokeThickness = 0.5, - Opacity = 0.5 - }); + minValue = 0; + maxValue = 1; + return; } + + double range = maxValue - minValue; + if (Math.Abs(range) < double.Epsilon) + { + double delta = Math.Max(Math.Abs(maxValue) * 0.05, 1); + minValue -= delta; + maxValue += delta; + return; + } + + double padding = range * 0.05; + minValue -= padding; + maxValue += padding; + } + + private void ApplyPlotValueZoom(ref double minValue, ref double maxValue) + { + if (Math.Abs(_plotValueZoomFactor - 1) < 0.0001) + return; + + double center = (minValue + maxValue) / 2; + double halfRange = (maxValue - minValue) * _plotValueZoomFactor / 2; + minValue = center - halfRange; + maxValue = center + halfRange; + } + + private static void DrawLine(Canvas canvas, double x1, double y1, double x2, double y2, Brush stroke, double thickness) + { + canvas.Children.Add(new Line + { + X1 = x1, + X2 = x2, + Y1 = y1, + Y2 = y2, + Stroke = stroke, + StrokeThickness = thickness + }); + } + + private static void AddPlotLabel(Canvas canvas, string text, double left, double top, Brush foreground, double fontSize, ushort fontWeight = 400) + { + TextBlock label = CreatePlotLabel(text, foreground, fontSize, fontWeight); + Canvas.SetLeft(label, left); + Canvas.SetTop(label, top); + canvas.Children.Add(label); + } + + private static TextBlock CreatePlotLabel(string text, Brush foreground, double fontSize, ushort fontWeight = 400) + { + return new TextBlock + { + Text = text, + FontSize = fontSize, + FontWeight = new global::Windows.UI.Text.FontWeight { Weight = fontWeight }, + Foreground = foreground + }; + } + + private static string FormatPlotAxisValue(double value) + { + double absoluteValue = Math.Abs(value); + if (absoluteValue >= 1000) + return value.ToString("F0", CultureInfo.CurrentCulture); + if (absoluteValue >= 100) + return value.ToString("F1", CultureInfo.CurrentCulture); + if (absoluteValue >= 10) + return value.ToString("F2", CultureInfo.CurrentCulture); + return value.ToString("F3", CultureInfo.CurrentCulture); + } + + private static string FormatPlotAge(TimeSpan age) + { + if (age.TotalHours >= 1) + return $"{(int)age.TotalHours}:{age.Minutes:00}"; + + return $"{(int)age.TotalMinutes}:{age.Seconds:00}"; + } + + private global::Windows.UI.Color GetPlotBackgroundColor() + { + return ViewModel.ThemeMode switch + { + AppThemeMode.Black => Colors.Black, + AppThemeMode.Dark => global::Windows.UI.Color.FromArgb(255, 24, 24, 24), + AppThemeMode.Auto when IsDarkPlotTheme() => global::Windows.UI.Color.FromArgb(255, 24, 24, 24), + _ => Colors.White + }; + } + + private global::Windows.UI.Color GetPlotBorderColor() + { + return IsDarkPlotTheme() + ? global::Windows.UI.Color.FromArgb(200, 210, 210, 210) + : global::Windows.UI.Color.FromArgb(180, 72, 72, 72); + } + + private global::Windows.UI.Color GetPlotGridColor() + { + return IsDarkPlotTheme() + ? global::Windows.UI.Color.FromArgb(85, 190, 190, 190) + : global::Windows.UI.Color.FromArgb(85, 96, 96, 96); + } + + private global::Windows.UI.Color GetPlotTextColor() + { + return IsDarkPlotTheme() + ? global::Windows.UI.Color.FromArgb(230, 245, 245, 245) + : global::Windows.UI.Color.FromArgb(230, 24, 24, 24); + } + + private global::Windows.UI.Color GetVisiblePlotColor(global::Windows.UI.Color color) + { + double luminance = GetRelativeLuminance(color); + if (IsDarkPlotTheme() && luminance < 0.35) + return MixColor(color, Colors.White, 0.45); + + if (!IsDarkPlotTheme() && luminance > 0.82) + return MixColor(color, Colors.Black, 0.4); + + return color; + } + + private bool IsDarkPlotTheme() + { + return ViewModel.ThemeMode switch + { + AppThemeMode.Black or AppThemeMode.Dark => true, + AppThemeMode.Light => false, + _ => _rootGrid != null && _rootGrid.ActualTheme == ElementTheme.Dark + }; + } + + private static global::Windows.UI.Color MixColor(global::Windows.UI.Color source, global::Windows.UI.Color target, double targetAmount) + { + targetAmount = Math.Clamp(targetAmount, 0, 1); + double sourceAmount = 1 - targetAmount; + return global::Windows.UI.Color.FromArgb( + source.A, + (byte)Math.Round(source.R * sourceAmount + target.R * targetAmount), + (byte)Math.Round(source.G * sourceAmount + target.G * targetAmount), + (byte)Math.Round(source.B * sourceAmount + target.B * targetAmount)); + } + + private static double GetRelativeLuminance(global::Windows.UI.Color color) + { + static double Linearize(byte channel) + { + double value = channel / 255.0; + return value <= 0.03928 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + return 0.2126 * Linearize(color.R) + 0.7152 * Linearize(color.G) + 0.0722 * Linearize(color.B); } private void UpdatePlotLayout() @@ -1542,6 +2044,9 @@ public sealed class MainWindow : Window AppThemeMode.Dark => new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 30, 30, 30)), _ => (Brush)Application.Current.Resources["ApplicationPageBackgroundThemeBrush"] }; + + _plotWindow?.ApplyTheme(ViewModel.ThemeMode); + DrawPlot(); } private void RestoreWindowBounds() @@ -1770,6 +2275,17 @@ public sealed class MainWindow : Window return item; } + private static ToggleMenuFlyoutItem CreateToggleSettingItem(string text, Func getter, Action setter) + { + ToggleMenuFlyoutItem item = new() + { + Text = text, + IsChecked = getter() + }; + item.Click += (_, _) => setter(item.IsChecked); + return item; + } + private static string[] GetLocalIPv4Addresses() { try @@ -1807,6 +2323,20 @@ public sealed class MainWindow : Window [DllImport("user32.dll")] private static extern uint GetDpiForWindow(IntPtr windowHandle); + private sealed record PlotBounds(double Left, double Top, double Width, double Height) + { + public double Bottom => Top + Height; + + public double Right => Left + Width; + } + + private sealed record PlotAxisLayout(SensorType SensorType, string Unit, double Top, double Height, double MinValue, double MaxValue) + { + public double Bottom => Top + Height; + } + + private sealed record PlotSeriesSample(PlotSeriesViewModel Series, IReadOnlyList Points); + private sealed record ParameterEditorRow(IParameter Parameter, Grid Container, CheckBox UseDefault, TextBox Value); private static void Bind(DependencyObject target, DependencyProperty property, object source, string path, BindingMode mode = BindingMode.OneWay) diff --git a/LibreHardwareMonitor.Windows.WinUI/PlotWindow.cs b/LibreHardwareMonitor.Windows.WinUI/PlotWindow.cs index 8aa5001..da4553e 100644 --- a/LibreHardwareMonitor.Windows.WinUI/PlotWindow.cs +++ b/LibreHardwareMonitor.Windows.WinUI/PlotWindow.cs @@ -4,6 +4,7 @@ using System; using LibreHardwareMonitor.Windows.WinUI.Services; +using LibreHardwareMonitor.Windows.WinUI.ViewModels; using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; @@ -18,6 +19,7 @@ public sealed class PlotWindow : Window { private readonly AppSettings _settings; private readonly AppWindow _appWindow; + private readonly Grid _root; private bool _closingFromOwner; public PlotWindow(AppSettings settings) @@ -28,14 +30,17 @@ public sealed class PlotWindow : Window _appWindow = AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd)); _appWindow.Title = "Sensor Plot"; + _root = new Grid(); PlotCanvas = new Canvas { MinWidth = 320, MinHeight = 220, - Background = (Brush)Application.Current.Resources["SystemControlBackgroundAltHighBrush"] + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch }; PlotCanvas.SizeChanged += (_, _) => PlotSizeChanged?.Invoke(this, EventArgs.Empty); - Content = PlotCanvas; + _root.Children.Add(PlotCanvas); + Content = _root; RestoreBounds(); Closed += PlotWindow_Closed; @@ -47,6 +52,16 @@ public sealed class PlotWindow : Window public Canvas PlotCanvas { get; } + public void ApplyTheme(AppThemeMode themeMode) + { + _root.RequestedTheme = themeMode switch + { + AppThemeMode.Light => ElementTheme.Light, + AppThemeMode.Dark or AppThemeMode.Black => ElementTheme.Dark, + _ => ElementTheme.Default + }; + } + public void CloseFromOwner() { _closingFromOwner = true; diff --git a/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs b/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs index 3086091..2a0ce63 100644 --- a/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs +++ b/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs @@ -76,7 +76,11 @@ public static class SensorFormatter public static double? GetPlotValue(ISensor sensor, TemperatureUnit temperatureUnit) { - float? value = sensor.Value; + return GetPlotValue(sensor, sensor.Value, temperatureUnit); + } + + public static double? GetPlotValue(ISensor sensor, float? value, TemperatureUnit temperatureUnit) + { if (!value.HasValue) return null; @@ -86,6 +90,36 @@ public static class SensorFormatter return value.Value; } + public static string GetPlotUnit(SensorType sensorType, TemperatureUnit temperatureUnit) + { + return sensorType switch + { + SensorType.Voltage => "V", + SensorType.Current => "A", + SensorType.Clock => "MHz", + SensorType.Load => "%", + SensorType.Temperature when temperatureUnit == TemperatureUnit.Fahrenheit => "\u00B0F", + SensorType.Temperature => "\u00B0C", + SensorType.Fan => "RPM", + SensorType.Flow => "L/h", + SensorType.Control => "%", + SensorType.Level => "%", + SensorType.Power => "W", + SensorType.Data => "GB", + SensorType.SmallData => "MB", + SensorType.Factor => "1", + SensorType.Frequency => "Hz", + SensorType.Throughput => "B/s", + SensorType.TimeSpan => "s", + SensorType.Timing => "ns", + SensorType.Energy => "mWh", + SensorType.Noise => "dBA", + SensorType.Conductivity => "\u00B5S/cm", + SensorType.Humidity => "%", + _ => "" + }; + } + public static string GetToolTip(ISensor sensor, TemperatureUnit temperatureUnit) { StringBuilder builder = new(); diff --git a/LibreHardwareMonitor.Windows.WinUI/ViewModels/MainWindowViewModel.cs b/LibreHardwareMonitor.Windows.WinUI/ViewModels/MainWindowViewModel.cs index 1115f70..ade2d8c 100644 --- a/LibreHardwareMonitor.Windows.WinUI/ViewModels/MainWindowViewModel.cs +++ b/LibreHardwareMonitor.Windows.WinUI/ViewModels/MainWindowViewModel.cs @@ -62,6 +62,27 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable TimeSpan.FromHours(24) ]; + private const int DefaultPlotTimeWindowIndex = 2; + private static readonly TimeSpan MaximumPlotPointRetention = TimeSpan.FromHours(24); + private static readonly TimeSpan MaximumSyntheticPlotPointRetention = TimeSpan.FromMinutes(5); + + private static readonly TimeSpan?[] PlotTimeWindows = + [ + null, + TimeSpan.FromMinutes(5), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(20), + TimeSpan.FromMinutes(30), + TimeSpan.FromMinutes(45), + TimeSpan.FromHours(1), + TimeSpan.FromMinutes(90), + TimeSpan.FromHours(2), + TimeSpan.FromHours(3), + TimeSpan.FromHours(6), + TimeSpan.FromHours(12), + TimeSpan.FromHours(24) + ]; + private static readonly Color[] PlotColors = [ Color.FromArgb(255, 0x00, 0x78, 0xD4), @@ -84,12 +105,17 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable private AppThemeMode _themeMode; private int _loggingIntervalIndex; private PlotLocation _plotLocation; + private bool _plotStackedAxes; private double _plotStrokeThickness; + private bool _plotTimeAxisZoomEnabled; + private int _plotTimeWindowIndex; + private bool _plotValueAxesZoomEnabled; private SensorTreeItemViewModel? _selectedItem; private int _sensorValuesTimeWindowIndex; private bool _showHiddenSensors; private bool _showMaxColumn; private bool _showMinColumn; + private bool _showPlotAxisLabels; private bool _showPlot; private bool _showValueColumn; private bool _throttleAtaUpdate; @@ -132,6 +158,12 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable _showPlot = settings.GetValue("plotMenuItem", false); _plotLocation = (PlotLocation)Math.Clamp(settings.GetValue("plotLocation", 0), 0, 2); _plotStrokeThickness = Math.Clamp(settings.GetValue("plotStroke", 1) + 1, 1, 4); + _plotStackedAxes = settings.GetValue("stackedAxes", true); + _showPlotAxisLabels = settings.GetValue("showAxesLabels", true); + _plotTimeAxisZoomEnabled = settings.GetValue("timeAxisEnableZoom", true); + _plotValueAxesZoomEnabled = settings.GetValue("yAxesEnableZoom", true); + int plotTimeWindowDefault = GetPlotTimeWindowIndex(settings.GetValue("plotPanel.MaxTimeSpan", 10.0f * 60)); + _plotTimeWindowIndex = Math.Clamp(settings.GetValue("plotTimeWindow", plotTimeWindowDefault), 0, PlotTimeWindows.Length - 1); _temperatureUnit = (TemperatureUnit)Math.Clamp(settings.GetValue("TemperatureUnit", 0), 0, 1); _updateIntervalIndex = Math.Clamp(settings.GetValue("updateIntervalMenuItem", 2), 0, UpdateIntervals.Length - 1); _loggingIntervalIndex = Math.Clamp(settings.GetValue("loggingInterval", 0), 0, LoggingIntervals.Length - 1); @@ -451,6 +483,19 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable public int PlotGridRowSpan => PlotLocation == PlotLocation.Right ? 2 : 1; + public bool PlotStackedAxes + { + get => _plotStackedAxes; + set + { + if (!SetProperty(ref _plotStackedAxes, value)) + return; + + Settings.SetValue("stackedAxes", value); + PlotInvalidated?.Invoke(this, EventArgs.Empty); + } + } + public double PlotStrokeThickness { get => _plotStrokeThickness; @@ -465,6 +510,49 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable } } + public TimeSpan? PlotTimeWindow => PlotTimeWindows[PlotTimeWindowIndex]; + + public bool PlotTimeAxisZoomEnabled + { + get => _plotTimeAxisZoomEnabled; + set + { + if (!SetProperty(ref _plotTimeAxisZoomEnabled, value)) + return; + + Settings.SetValue("timeAxisEnableZoom", value); + } + } + + public int PlotTimeWindowIndex + { + get => _plotTimeWindowIndex; + set + { + value = Math.Clamp(value, 0, PlotTimeWindows.Length - 1); + if (!SetProperty(ref _plotTimeWindowIndex, value)) + return; + + Settings.SetValue("plotTimeWindow", value); + Settings.SetValue("plotPanel.MinTimeSpan", 0.0f); + Settings.SetValue("plotPanel.MaxTimeSpan", PlotTimeWindow.HasValue ? (float)PlotTimeWindow.Value.TotalSeconds : float.NaN); + OnPropertyChanged(nameof(PlotTimeWindow)); + PlotInvalidated?.Invoke(this, EventArgs.Empty); + } + } + + public bool PlotValueAxesZoomEnabled + { + get => _plotValueAxesZoomEnabled; + set + { + if (!SetProperty(ref _plotValueAxesZoomEnabled, value)) + return; + + Settings.SetValue("yAxesEnableZoom", value); + } + } + public bool ShowHiddenSensors { get => _showHiddenSensors; @@ -504,6 +592,19 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable } } + public bool ShowPlotAxisLabels + { + get => _showPlotAxisLabels; + set + { + if (!SetProperty(ref _showPlotAxisLabels, value)) + return; + + Settings.SetValue("showAxesLabels", value); + PlotInvalidated?.Invoke(this, EventArgs.Empty); + } + } + public bool ShowPlot { get => _showPlot; @@ -670,6 +771,12 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable PlotInvalidated?.Invoke(this, EventArgs.Empty); } + public void RefreshPlotSeries() + { + TrackPlotPoints(); + PlotInvalidated?.Invoke(this, EventArgs.Empty); + } + public void Save() { Settings.Save(); @@ -762,7 +869,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable await MeasureStartupAsync("MainWindowViewModel.HardwareMonitor.OpenAsync", () => _hardwareMonitor.OpenAsync(raiseTreeRebuilt: false)); MeasureStartup("MainWindowViewModel.UpdateRoot", UpdateRoot, GetRootDetail); StatusText = "Reading sensor values..."; - await MeasureStartupAsync("MainWindowViewModel.InitialSensorValueUpdate", () => RefreshSensorValuesAsync(trackPlotPoints: false, logSensors: false)); + await MeasureStartupAsync("MainWindowViewModel.InitialSensorValueUpdate", () => RefreshSensorValuesAsync(trackPlotPoints: true, logSensors: false)); MeasureStartup("MainWindowViewModel.StartWebServerFromSettings", StartWebServerFromSettings); MeasureStartup("MainWindowViewModel.UpdateStatus", UpdateStatus, GetRootDetail); _isStarted = true; @@ -942,33 +1049,74 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable if (root == null) return; - DateTime now = DateTime.Now; + DateTime now = DateTime.UtcNow; HashSet selectedIdentifiers = new(); foreach (SensorTreeItemViewModel sensorItem in root.EnumerateSensors().Where(sensorItem => sensorItem.Plot && sensorItem.Sensor != null)) { ISensor sensor = sensorItem.Sensor!; string identifier = sensor.Identifier.ToString(); selectedIdentifiers.Add(identifier); - double? value = SensorFormatter.GetPlotValue(sensor, TemperatureUnit); - if (!value.HasValue) - continue; if (!_plotSeriesByIdentifier.TryGetValue(identifier, out PlotSeriesViewModel? series)) { - series = new PlotSeriesViewModel(identifier, sensor.Name, GetPlotColor(sensorItem)); + series = new PlotSeriesViewModel( + identifier, + sensor.Hardware.Name, + sensor.Name, + sensor.SensorType, + SensorFormatter.GetPlotUnit(sensor.SensorType, TemperatureUnit), + GetPlotColor(sensorItem)); _plotSeriesByIdentifier[identifier] = series; PlotSeries.Add(series); } - else if (sensorItem.PenColor.HasValue) + else { + series.UpdateMetadata(sensor.Hardware.Name, sensor.Name, sensor.SensorType, SensorFormatter.GetPlotUnit(sensor.SensorType, TemperatureUnit)); + // Only honor an explicit user pen color for an existing series; keep the auto-assigned color stable. // (Recomputing it from the live series count made existing lines shift/collide colors every tick.) - series.Color = sensorItem.PenColor.Value; + if (sensorItem.PenColor.HasValue) + series.Color = sensorItem.PenColor.Value; } - series.Points.Add(new PlotPointViewModel(now, value.Value)); - while (series.Points.Count > 0 && now - series.Points[0].Timestamp > TimeSpan.FromHours(24)) - series.Points.RemoveAt(0); + List points = []; + foreach (SensorValue sensorValue in sensor.Values.OrderBy(value => value.Time)) + { + double? displayedValue = SensorFormatter.GetPlotValue(sensor, sensorValue.Value, TemperatureUnit); + if (displayedValue is not { } pointValue || !double.IsFinite(pointValue)) + continue; + + points.Add(new PlotPointViewModel(sensorValue.Time, pointValue)); + } + + DateTime? latestHistoryTimestamp = points.Count > 0 ? points[^1].Timestamp : null; + foreach (PlotPointViewModel existingPoint in series.Points) + { + if (now - existingPoint.Timestamp > MaximumSyntheticPlotPointRetention) + continue; + + if (!latestHistoryTimestamp.HasValue || existingPoint.Timestamp > latestHistoryTimestamp.Value) + points.Add(existingPoint); + } + + double? currentValue = SensorFormatter.GetPlotValue(sensor, TemperatureUnit); + if (currentValue is { } currentPointValue && double.IsFinite(currentPointValue)) + points.Add(new PlotPointViewModel(now, currentPointValue)); + + DateTime cutoff = now - MaximumPlotPointRetention; + points = points + .Where(point => point.Timestamp >= cutoff) + .GroupBy(point => point.Timestamp.Ticks) + .Select(group => group.Last()) + .OrderBy(point => point.Timestamp) + .ToList(); + + if (points.Count == 0 && currentValue is { } fallbackPointValue && double.IsFinite(fallbackPointValue)) + { + points.Add(new PlotPointViewModel(now, fallbackPointValue)); + } + + series.ReplacePoints(points); } foreach (string identifier in _plotSeriesByIdentifier.Keys.Where(identifier => !selectedIdentifiers.Contains(identifier)).ToArray()) @@ -1053,6 +1201,30 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable }; } + private static int GetPlotTimeWindowIndex(float maxSeconds) + { + if (!float.IsFinite(maxSeconds)) + return 0; + + int bestIndex = DefaultPlotTimeWindowIndex; + double bestDistance = double.MaxValue; + for (int i = 1; i < PlotTimeWindows.Length; i++) + { + TimeSpan? timeWindow = PlotTimeWindows[i]; + if (!timeWindow.HasValue) + continue; + + double distance = Math.Abs(timeWindow.Value.TotalSeconds - maxSeconds); + if (distance < bestDistance) + { + bestDistance = distance; + bestIndex = i; + } + } + + return bestIndex; + } + private static string FormatInterval(TimeSpan interval) { if (interval.TotalMilliseconds < 1000) diff --git a/LibreHardwareMonitor.Windows.WinUI/ViewModels/PlotSeriesViewModel.cs b/LibreHardwareMonitor.Windows.WinUI/ViewModels/PlotSeriesViewModel.cs index e7123ae..6b964cd 100644 --- a/LibreHardwareMonitor.Windows.WinUI/ViewModels/PlotSeriesViewModel.cs +++ b/LibreHardwareMonitor.Windows.WinUI/ViewModels/PlotSeriesViewModel.cs @@ -2,25 +2,51 @@ // If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // Copyright (C) LibreHardwareMonitor and Contributors. -using System.Collections.ObjectModel; +using System.Collections.Generic; +using LibreHardwareMonitor.Hardware; using Windows.UI; namespace LibreHardwareMonitor.Windows.WinUI.ViewModels; public sealed class PlotSeriesViewModel { - public PlotSeriesViewModel(string sensorIdentifier, string name, Color color) + public PlotSeriesViewModel(string sensorIdentifier, string hardwareName, string name, SensorType sensorType, string unit, Color color) { SensorIdentifier = sensorIdentifier; + HardwareName = hardwareName; Name = name; + SensorType = sensorType; + Unit = unit; Color = color; } public Color Color { get; set; } - public string Name { get; } + public string HardwareName { get; private set; } - public ObservableCollection Points { get; } = []; + public string Name { get; private set; } + + public List Points { get; } = []; public string SensorIdentifier { get; } + + public SensorType SensorType { get; private set; } + + public string Title => string.IsNullOrWhiteSpace(HardwareName) ? Name : $"{HardwareName} {Name}"; + + public string Unit { get; private set; } + + public void ReplacePoints(IEnumerable points) + { + Points.Clear(); + Points.AddRange(points); + } + + public void UpdateMetadata(string hardwareName, string name, SensorType sensorType, string unit) + { + HardwareName = hardwareName; + Name = name; + SensorType = sensorType; + Unit = unit; + } }