plot window refactor
This commit is contained in:
@@ -144,6 +144,29 @@ public class SensorFormatterTests
|
||||
Assert.Equal(25.5f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlotValue_HistoricalTemperatureFahrenheit_ReturnsConvertedValue()
|
||||
{
|
||||
var mockSensor = new Mock<ISensor>();
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ToggleMenuFlyoutItem>())
|
||||
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<PlotSeriesSample> 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<SensorType, PlotSeriesSample>[] 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<SensorType, PlotSeriesSample> 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<PlotSeriesSample> 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<PlotSeriesSample> 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<bool> getter, Action<bool> 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<PlotPointViewModel> 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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string> 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<PlotPointViewModel> 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)
|
||||
|
||||
@@ -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<PlotPointViewModel> Points { get; } = [];
|
||||
public string Name { get; private set; }
|
||||
|
||||
public List<PlotPointViewModel> 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<PlotPointViewModel> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user