// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. // 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; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using LibreHardwareMonitor.Hardware; using LibreHardwareMonitor.Windows.WinUI.Utilities; using Windows.UI; namespace LibreHardwareMonitor.Windows.WinUI.ViewModels; /// /// Owns the plot's series collection and reconciles it with the currently selected sensors on each update. Extracted /// from so the (subtle) point-retention/merge logic can be unit-tested in isolation. /// internal sealed class PlotTrackingService { private static readonly TimeSpan MaximumPlotPointRetention = TimeSpan.FromHours(24); private static readonly TimeSpan MaximumSyntheticPlotPointRetention = TimeSpan.FromMinutes(5); private static readonly Color[] PlotColors = [ Color.FromArgb(255, 0x00, 0x78, 0xD4), Color.FromArgb(255, 0xE8, 0x11, 0x23), Color.FromArgb(255, 0x10, 0x7C, 0x10), Color.FromArgb(255, 0xF7, 0x63, 0x0C), Color.FromArgb(255, 0x88, 0x17, 0x98), Color.FromArgb(255, 0x00, 0xB7, 0xC3), Color.FromArgb(255, 0x49, 0x8B, 0x00), Color.FromArgb(255, 0xA8, 0x00, 0x00) ]; private readonly Dictionary _seriesByIdentifier = new(); /// The live series collection, bound by the plot view. public ObservableCollection Series { get; } = []; /// /// Reconciles with the sensors under that are flagged for plotting: /// adds/updates a series per selected sensor (merging stored history, retained synthetic points, and the current /// value, de-duplicated by timestamp and pruned to the retention window) and removes series no longer selected. /// public void Track(SensorTreeItemViewModel? root, TemperatureUnit temperatureUnit) { if (root == null) return; 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); if (!_seriesByIdentifier.TryGetValue(identifier, out PlotSeriesViewModel? series)) { series = new PlotSeriesViewModel( identifier, sensor.Hardware.Name, sensor.Name, sensor.SensorType, SensorFormatter.GetPlotUnit(sensor.SensorType, temperatureUnit), GetPlotColor(sensorItem)); _seriesByIdentifier[identifier] = series; Series.Add(series); } 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.) if (sensorItem.PenColor.HasValue) series.Color = sensorItem.PenColor.Value; } // The pipeline below sorts and de-duplicates, so there is no need to OrderBy the whole (potentially 24h) // history every tick; track the latest timestamp in the same pass instead of assuming the list is ordered. List points = []; DateTime? latestHistoryTimestamp = null; foreach (SensorValue sensorValue in sensor.Values) { double? displayedValue = SensorFormatter.GetPlotValue(sensor, sensorValue.Value, temperatureUnit); if (displayedValue is not { } pointValue || !double.IsFinite(pointValue)) continue; points.Add(new PlotPointViewModel(sensorValue.Time, pointValue)); if (!latestHistoryTimestamp.HasValue || sensorValue.Time > latestHistoryTimestamp.Value) latestHistoryTimestamp = sensorValue.Time; } 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 _seriesByIdentifier.Keys.Where(identifier => !selectedIdentifiers.Contains(identifier)).ToArray()) { PlotSeriesViewModel series = _seriesByIdentifier[identifier]; _seriesByIdentifier.Remove(identifier); Series.Remove(series); } } /// Clears all tracked series (used when resetting the plot). public void Reset() { _seriesByIdentifier.Clear(); Series.Clear(); } /// Re-applies the color for an existing series after its sensor's pen color changed. public void RefreshSeriesColor(SensorTreeItemViewModel sensorItem) { if (sensorItem.Sensor == null) return; if (_seriesByIdentifier.TryGetValue(sensorItem.Sensor.Identifier.ToString(), out PlotSeriesViewModel? series)) series.Color = GetPlotColor(sensorItem); } private Color GetPlotColor(SensorTreeItemViewModel sensorItem) { return sensorItem.PenColor ?? PlotColors[_seriesByIdentifier.Count % PlotColors.Length]; } }