// /*************************************************************************** // Aaru Data Preservation Suite // ---------------------------------------------------------------------------- // // Filename : DiscSpeedGraph.axaml.cs // Author(s) : Natalia Portillo // // Component : GUI custom controls. // // --[ Description ] ---------------------------------------------------------- // // A disc speed graph control to visualize read/write speeds over sectors. // // --[ License ] -------------------------------------------------------------- // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the // "Software"), to deal in the Software without restriction, including // without limitation the rights to use, copy, modify, merge, publish, // distribute, sublicense, and/or sell copies of the Software, and to // permit persons to whom the Software is furnished to do so, subject to // the following conditions: // // The above copyright notice and this permission notice shall be included // in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY // CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, // TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // // ---------------------------------------------------------------------------- // Copyright © 2011-2025 Natalia Portillo // ****************************************************************************/ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Shapes; using Avalonia.Markup.Xaml; using Avalonia.Media; namespace Aaru.Gui.Controls; /// /// Disc speed graph control that visualizes read/write speeds over sectors, /// similar to Nero DiscSpeed and ImgBurn speed graphs. /// public partial class DiscSpeedGraph : UserControl { const int MARGIN_LEFT = 60; // Space for Y-axis labels (KB/s) const int MARGIN_RIGHT = 60; // Space for Y-axis labels (speed rating) const int MARGIN_TOP = 20; // Top margin const int MARGIN_BOTTOM = 30; // Space for X-axis labels public static readonly StyledProperty> SpeedDataProperty = AvaloniaProperty .Register>(nameof(SpeedData)); public static readonly StyledProperty MaxSectorProperty = AvaloniaProperty.Register(nameof(MaxSector)); public static readonly StyledProperty MaxSpeedProperty = AvaloniaProperty.Register(nameof(MaxSpeed)); public static readonly StyledProperty MultiplierProperty = AvaloniaProperty.Register(nameof(Multiplier), 1353); readonly Canvas _canvas; readonly List _gridLines = []; readonly List _labels = []; readonly List<(ulong sector, double speedKbps)> _processedData = []; readonly Polyline _speedLine; readonly List _speedWindow = new(30); // Window of recent non-spike speeds int _consecutiveSpikeCount; // Counter for consecutive spike attenuation ObservableCollection<(ulong sector, double speedKbps)> _speedData; public DiscSpeedGraph() { InitializeComponent(); _canvas = this.FindControl("GraphCanvas"); // Create the speed line (aqua colored) _speedLine = new Polyline { Stroke = new SolidColorBrush(Color.FromRgb(0, 255, 255)), // Aqua StrokeThickness = 2 }; _canvas?.Children.Add(_speedLine); } public ObservableCollection<(ulong sector, double speedKbps)> SpeedData { get => GetValue(SpeedDataProperty); set => SetValue(SpeedDataProperty, value); } public ulong MaxSector { get => GetValue(MaxSectorProperty); set => SetValue(MaxSectorProperty, value); } public double MaxSpeed { get => GetValue(MaxSpeedProperty); set => SetValue(MaxSpeedProperty, value); } public int Multiplier { get => GetValue(MultiplierProperty); set => SetValue(MultiplierProperty, value); } void InitializeComponent() { AvaloniaXamlLoader.Load(this); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if(change.Property == SpeedDataProperty) { if(_speedData != null) _speedData.CollectionChanged -= OnSpeedDataChanged; _speedData = change.GetNewValue>(); if(_speedData == null) return; _speedData.CollectionChanged += OnSpeedDataChanged; _processedData.Clear(); RedrawAll(); } else if(change.Property == MaxSectorProperty || change.Property == MaxSpeedProperty || change.Property == MultiplierProperty || change.Property == BoundsProperty) RedrawAll(); } void OnSpeedDataChanged(object sender, NotifyCollectionChangedEventArgs e) { if(e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null) { _processedData.AddRange(from (ulong sector, double speedKbps) item in e.NewItems select ProcessNewDataPoint(item)); DrawNewSegment(); } else { _processedData.Clear(); _speedWindow.Clear(); _consecutiveSpikeCount = 0; RedrawAll(); } } (ulong sector, double speedKbps) ProcessNewDataPoint((ulong sector, double speedKbps) newPoint) { (ulong sector, double speed) = newPoint; // Skip zero/negative speeds if(speed <= 0) return newPoint; // Build initial window before spike detection if(_speedWindow.Count < 15) { _speedWindow.Add(speed); _consecutiveSpikeCount = 0; return newPoint; } // Get quartiles of the speed window var sortedWindow = _speedWindow.Order().ToList(); double q1 = sortedWindow[sortedWindow.Count / 4]; double q3 = sortedWindow[sortedWindow.Count * 3 / 4]; double iqr = q3 - q1; // Outlier threshold: Q3 + 1.5*IQR (standard statistical definition) double outlierThreshold = q3 + 1.5 * iqr; double processedSpeed = speed; // Only attenuate EXTREME outliers: must be BOTH above threshold AND more than 3x Q3 if(speed > outlierThreshold && speed > q3 * 3.0) { _consecutiveSpikeCount++; // Only attenuate up to 5 consecutive spikes if(_consecutiveSpikeCount <= 5) { // Cap the spike to 0.95 * Q3 (gentle smoothing) processedSpeed = q3 * 0.95; } // After 5 consecutive spikes, assume it's a real speed region and stop attenuating } else { // Speed is normal or part of a sustained high-speed region _consecutiveSpikeCount = 0; } // Always add the processed speed to window _speedWindow.Add(processedSpeed); // Keep window size at 30 samples if(_speedWindow.Count > 30) _speedWindow.RemoveAt(0); return (sector, processedSpeed); } void RedrawAll() { if(_canvas == null || Bounds.Width <= 0 || Bounds.Height <= 0) return; // Clear everything ClearGraph(); // Process all data if(_speedData?.Count > 0) { _processedData.Clear(); _speedWindow.Clear(); _consecutiveSpikeCount = 0; _processedData.AddRange(_speedData.Select(ProcessNewDataPoint)); } // Draw background grid DrawGrid(); // Draw speed line DrawSpeedLine(); } void ClearGraph() { // Remove all grid lines and labels foreach(Line line in _gridLines) _canvas.Children.Remove(line); foreach(TextBlock label in _labels) _canvas.Children.Remove(label); _gridLines.Clear(); _labels.Clear(); // Clear the speed line _speedLine.Points.Clear(); } void DrawGrid() { if(MaxSpeed <= 0 || MaxSector <= 0) return; double graphWidth = Bounds.Width - MARGIN_LEFT - MARGIN_RIGHT; double graphHeight = Bounds.Height - MARGIN_TOP - MARGIN_BOTTOM; if(graphWidth <= 0 || graphHeight <= 0) return; var grayBrush = new SolidColorBrush(Color.FromRgb(64, 64, 64)); IImmutableSolidColorBrush whiteBrush = Brushes.White; // Draw vertical grid lines every 10% for(var i = 0; i <= 10; i++) { double x = MARGIN_LEFT + graphWidth * i / 10.0; var line = new Line { StartPoint = new Point(x, MARGIN_TOP), EndPoint = new Point(x, MARGIN_TOP + graphHeight), Stroke = grayBrush, StrokeThickness = 1 }; _canvas.Children.Add(line); _gridLines.Add(line); // Add X-axis labels (sector numbers) if(MaxSector <= 0) continue; ulong sector = MaxSector * (ulong)i / 10ul; var sectorText = new TextBlock { Text = sector.ToString("N0"), Foreground = whiteBrush, FontSize = 10 }; _labels.Add(sectorText); _canvas.Children.Add(sectorText); // Measure text to center it sectorText.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); Canvas.SetLeft(sectorText, x - sectorText.DesiredSize.Width / 2); Canvas.SetTop(sectorText, MARGIN_TOP + graphHeight + 5); } // Draw horizontal grid lines and labels const int numHorizontalLines = 10; for(var i = 0; i <= numHorizontalLines; i++) { double y = MARGIN_TOP + graphHeight * (1 - (double)i / numHorizontalLines); var line = new Line { StartPoint = new Point(MARGIN_LEFT, y), EndPoint = new Point(MARGIN_LEFT + graphWidth, y), Stroke = grayBrush, StrokeThickness = 1 }; _canvas.Children.Add(line); _gridLines.Add(line); } // Add Y-axis labels (KB/s on left, speed rating on right) for(var i = 0; i <= numHorizontalLines; i++) { double y = MARGIN_TOP + graphHeight * (1 - (double)i / numHorizontalLines); double speed = MaxSpeed * i / numHorizontalLines; // Left side: KB/s var kbpsText = new TextBlock { Text = speed.ToString("F0"), Foreground = whiteBrush, FontSize = 10 }; _labels.Add(kbpsText); _canvas.Children.Add(kbpsText); kbpsText.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); Canvas.SetLeft(kbpsText, MARGIN_LEFT - kbpsText.DesiredSize.Width - 5); Canvas.SetTop(kbpsText, y - kbpsText.DesiredSize.Height / 2); // Right side: Speed rating (e.g., 48x) if(Multiplier <= 0) continue; double speedRating = speed / Multiplier; var ratingText = new TextBlock { Text = $"{speedRating:F1}x", Foreground = whiteBrush, FontSize = 10 }; _labels.Add(ratingText); _canvas.Children.Add(ratingText); ratingText.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); Canvas.SetLeft(ratingText, MARGIN_LEFT + graphWidth + 5); Canvas.SetTop(ratingText, y - ratingText.DesiredSize.Height / 2); } } void DrawSpeedLine() { if(_processedData.Count == 0 || MaxSpeed <= 0 || MaxSector <= 0) return; double graphWidth = Bounds.Width - MARGIN_LEFT - MARGIN_RIGHT; double graphHeight = Bounds.Height - MARGIN_TOP - MARGIN_BOTTOM; if(graphWidth <= 0 || graphHeight <= 0) return; _speedLine.Points.Clear(); foreach((ulong sector, double speedKbps) in _processedData) { double x = MARGIN_LEFT + graphWidth * sector / MaxSector; double y = MARGIN_TOP + graphHeight * (1 - speedKbps / MaxSpeed); // Clamp Y to graph bounds y = Math.Max(MARGIN_TOP, Math.Min(MARGIN_TOP + graphHeight, y)); _speedLine.Points.Add(new Point(x, y)); } } void DrawNewSegment() { if(_processedData.Count == 0 || MaxSpeed <= 0 || MaxSector <= 0) return; double graphWidth = Bounds.Width - MARGIN_LEFT - MARGIN_RIGHT; double graphHeight = Bounds.Height - MARGIN_TOP - MARGIN_BOTTOM; if(graphWidth <= 0 || graphHeight <= 0) return; // Only add the new point(s) to the polyline int startIndex = _speedLine.Points.Count; for(int i = startIndex; i < _processedData.Count; i++) { (ulong sector, double speedKbps) = _processedData[i]; double x = MARGIN_LEFT + graphWidth * sector / MaxSector; double y = MARGIN_TOP + graphHeight * (1 - speedKbps / MaxSpeed); // Clamp Y to graph bounds y = Math.Max(MARGIN_TOP, Math.Min(MARGIN_TOP + graphHeight, y)); _speedLine.Points.Add(new Point(x, y)); } } }