plot window refactor

This commit is contained in:
2026-06-01 18:45:00 -05:00
parent 667cb4117f
commit 54a88e1f31
7 changed files with 899 additions and 77 deletions
@@ -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);
}
}
+587 -57
View File
@@ -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;
}
}