mirror of
https://github.com/aaru-dps/Aaru.git
synced 2025-12-16 19:24:25 +00:00
484 lines
16 KiB
C#
484 lines
16 KiB
C#
// /***************************************************************************
|
|
// Aaru Data Preservation Suite
|
|
// ----------------------------------------------------------------------------
|
|
//
|
|
// Filename : DiscSpeedGraph.axaml.cs
|
|
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
|
//
|
|
// 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.Input;
|
|
using Avalonia.Markup.Xaml;
|
|
using Avalonia.Media;
|
|
|
|
namespace Aaru.Gui.Controls;
|
|
|
|
/// <summary>
|
|
/// Disc speed graph control that visualizes read/write speeds over sectors,
|
|
/// similar to Nero DiscSpeed and ImgBurn speed graphs.
|
|
/// </summary>
|
|
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
|
|
const double MIN_ZOOM = 0.1; // 10% minimum zoom (zoomed out)
|
|
const double MAX_ZOOM = 50.0; // 5000% maximum zoom (zoomed in) increased from 10.0
|
|
const double ZOOM_STEP = 0.2; // 20% zoom step per click/scroll
|
|
|
|
public static readonly StyledProperty<ObservableCollection<(ulong sector, double speedKbps)>> SpeedDataProperty =
|
|
AvaloniaProperty
|
|
.Register<DiscSpeedGraph, ObservableCollection<(ulong sector, double speedKbps)>>(nameof(SpeedData));
|
|
|
|
public static readonly StyledProperty<ulong> MaxSectorProperty =
|
|
AvaloniaProperty.Register<DiscSpeedGraph, ulong>(nameof(MaxSector));
|
|
|
|
public static readonly StyledProperty<double> MaxSpeedProperty =
|
|
AvaloniaProperty.Register<DiscSpeedGraph, double>(nameof(MaxSpeed));
|
|
|
|
public static readonly StyledProperty<int> MultiplierProperty =
|
|
AvaloniaProperty.Register<DiscSpeedGraph, int>(nameof(Multiplier), 1353);
|
|
|
|
readonly Canvas _canvas;
|
|
readonly List<Line> _gridLines = [];
|
|
readonly List<TextBlock> _labels = [];
|
|
readonly List<(ulong sector, double speedKbps)> _processedData = [];
|
|
readonly Polyline _speedLine;
|
|
readonly List<double> _speedWindow = new(30); // Window of recent non-spike speeds
|
|
int _consecutiveSpikeCount; // Counter for consecutive spike attenuation
|
|
ObservableCollection<(ulong sector, double speedKbps)> _speedData;
|
|
|
|
double _yZoomLevel = 1.0; // 1.0 = 100% (no zoom), higher = zoomed in
|
|
|
|
public DiscSpeedGraph()
|
|
{
|
|
InitializeComponent();
|
|
_canvas = this.FindControl<Canvas>("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);
|
|
|
|
// Wire up scroll wheel for zoom
|
|
if(_canvas != null) _canvas.PointerWheelChanged += OnPointerWheelChanged;
|
|
|
|
// Keyboard shortcuts: + to zoom in, - to zoom out
|
|
KeyDown += OnKeyDown;
|
|
}
|
|
|
|
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<ObservableCollection<(ulong sector, double speedKbps)>>();
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
void ZoomIn()
|
|
{
|
|
double newZoom = Math.Min(_yZoomLevel + ZOOM_STEP, MAX_ZOOM);
|
|
|
|
if(!(Math.Abs(newZoom - _yZoomLevel) > 0.001)) return;
|
|
|
|
_yZoomLevel = newZoom;
|
|
RedrawAll();
|
|
}
|
|
|
|
void ZoomOut()
|
|
{
|
|
double newZoom = Math.Max(_yZoomLevel - ZOOM_STEP, MIN_ZOOM);
|
|
|
|
if(!(Math.Abs(newZoom - _yZoomLevel) > 0.001)) return;
|
|
|
|
_yZoomLevel = newZoom;
|
|
RedrawAll();
|
|
}
|
|
|
|
void OnPointerWheelChanged(object sender, PointerWheelEventArgs e)
|
|
{
|
|
// Scroll up = zoom in, scroll down = zoom out
|
|
if(e.Delta.Y > 0)
|
|
ZoomIn();
|
|
else if(e.Delta.Y < 0) ZoomOut();
|
|
|
|
e.Handled = true;
|
|
}
|
|
|
|
void OnKeyDown(object? sender, KeyEventArgs e)
|
|
{
|
|
switch(e.Key)
|
|
{
|
|
case Key.OemPlus: // Usually needs Shift
|
|
case Key.Add: // Numpad +
|
|
ZoomIn();
|
|
e.Handled = true;
|
|
|
|
break;
|
|
case Key.OemMinus:
|
|
case Key.Subtract: // Numpad -
|
|
ZoomOut();
|
|
e.Handled = true;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
(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 / _yZoomLevel * 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();
|
|
|
|
double effectiveMaxSpeed = MaxSpeed / _yZoomLevel;
|
|
|
|
foreach((ulong sector, double speedKbps) in _processedData)
|
|
{
|
|
double x = MARGIN_LEFT + graphWidth * sector / MaxSector;
|
|
double y = MARGIN_TOP + graphHeight * (1 - speedKbps / effectiveMaxSpeed);
|
|
|
|
// 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;
|
|
|
|
double effectiveMaxSpeed = MaxSpeed / _yZoomLevel;
|
|
|
|
// 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 / effectiveMaxSpeed);
|
|
|
|
// Clamp Y to graph bounds
|
|
y = Math.Max(MARGIN_TOP, Math.Min(MARGIN_TOP + graphHeight, y));
|
|
|
|
_speedLine.Points.Add(new Point(x, y));
|
|
}
|
|
}
|
|
} |