[GUI] Add BlockMap control for visualizing sector access times

This commit is contained in:
2025-11-18 17:14:22 +00:00
parent b37007321e
commit 9f0109af43
3 changed files with 273 additions and 491 deletions

View File

@@ -0,0 +1,11 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="Aaru.Gui.Controls.BlockMap">
<Canvas Name="BlockCanvas"
Background="Transparent" />
</UserControl>

View File

@@ -0,0 +1,262 @@
// /***************************************************************************
// Aaru Data Preservation Suite
// ----------------------------------------------------------------------------
//
// Filename : BlockMap.cs
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// Component : GUI custom controls.
//
// --[ Description ] ----------------------------------------------------------
//
// A block map control to visualize sector access times.
//
// --[ 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.ObjectModel;
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
namespace Aaru.Gui.Controls;
public partial class BlockMap : UserControl
{
const int BlockSize = 4; // Size of each block in pixels
const int BlockSpacing = 1; // Spacing between blocks
const double MinDuration = 1.0; // Green threshold (ms)
const double MaxDuration = 500.0; // Red threshold (ms)
public static readonly StyledProperty<ObservableCollection<(ulong startingSector, double duration)>>
SectorDataProperty =
AvaloniaProperty
.Register<BlockMap, ObservableCollection<(ulong startingSector, double duration)>>(nameof(SectorData));
public static readonly StyledProperty<uint> ScanBlockSizeProperty =
AvaloniaProperty.Register<BlockMap, uint>(nameof(ScanBlockSize), 1u);
int _blocksPerRow;
readonly Canvas _canvas;
uint _scanBlockSize = 1;
ObservableCollection<(ulong startingSector, double duration)> _sectorData;
int _totalBlocksDrawn;
public BlockMap()
{
InitializeComponent();
_canvas = this.FindControl<Canvas>("BlockCanvas");
}
public ObservableCollection<(ulong startingSector, double duration)> SectorData
{
get => GetValue(SectorDataProperty);
set => SetValue(SectorDataProperty, value);
}
public uint ScanBlockSize
{
get => GetValue(ScanBlockSizeProperty);
set => SetValue(ScanBlockSizeProperty, value);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if(change.Property == SectorDataProperty)
{
if(_sectorData != null) _sectorData.CollectionChanged -= OnSectorDataChanged;
_sectorData = change.GetNewValue<ObservableCollection<(ulong startingSector, double duration)>>();
if(_sectorData != null)
{
_sectorData.CollectionChanged += OnSectorDataChanged;
RedrawAll();
}
}
else if(change.Property == ScanBlockSizeProperty)
{
_scanBlockSize = change.GetNewValue<uint>();
RedrawAll();
}
else if(change.Property == BoundsProperty)
{
CalculateBlocksPerRow();
RedrawAll();
}
}
private void OnSectorDataChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if(e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null)
{
// Incremental draw for added items
DrawNewBlocks(e.NewStartingIndex, e.NewItems.Count);
}
else
{
// Full redraw for other operations
RedrawAll();
}
}
private void CalculateBlocksPerRow()
{
if(Bounds.Width <= 0) return;
var availableWidth = (int)Bounds.Width;
int blockWithSpacing = BlockSize + BlockSpacing;
_blocksPerRow = Math.Max(1, availableWidth / blockWithSpacing);
}
private void RedrawAll()
{
if(_canvas == null || _sectorData == null || _sectorData.Count == 0) return;
_canvas.Children.Clear();
CalculateBlocksPerRow();
_totalBlocksDrawn = 0;
DrawNewBlocks(0, _sectorData.Count);
}
private void DrawNewBlocks(int startIndex, int count)
{
if(_canvas == null || _sectorData == null || _blocksPerRow == 0) return;
int blockWithSpacing = BlockSize + BlockSpacing;
for(int i = startIndex; i < startIndex + count && i < _sectorData.Count; i++)
{
(ulong startingSector, double duration) = _sectorData[i];
Color color = GetColorForDuration(duration);
// Calculate position in grid
int blockIndex = _totalBlocksDrawn;
int row = blockIndex / _blocksPerRow;
int col = blockIndex % _blocksPerRow;
// Create and position rectangle
var rect = new Border
{
Width = BlockSize,
Height = BlockSize,
Background = new SolidColorBrush(color),
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(0)
};
Canvas.SetLeft(rect, col * blockWithSpacing);
Canvas.SetTop(rect, row * blockWithSpacing);
_canvas.Children.Add(rect);
_totalBlocksDrawn++;
}
// Update canvas height based on rows needed
int totalRows = (_totalBlocksDrawn + _blocksPerRow - 1) / _blocksPerRow;
_canvas.Height = totalRows * blockWithSpacing;
}
private Color GetColorForDuration(double duration)
{
// Clamp duration between min and max
double clampedDuration = Math.Max(MinDuration, Math.Min(MaxDuration, duration));
// Calculate normalized position (0 = green/fast, 1 = red/slow)
double normalized = (clampedDuration - MinDuration) / (MaxDuration - MinDuration);
// Interpolate through color spectrum with more gradients:
// Green -> Lime -> Yellow -> Orange -> Red-Orange -> Dark Red
if(normalized <= 0.17) // Green to Lime
{
double t = normalized / 0.17;
return Color.FromRgb((byte)(0 + t * 128), // R: 0 -> 128
255, // G: stays 255
0 // B: stays 0
);
}
if(normalized <= 0.34) // Lime to Yellow
{
double t = (normalized - 0.17) / 0.17;
return Color.FromRgb((byte)(128 + t * 127), // R: 128 -> 255
255, // G: stays 255
0 // B: stays 0
);
}
if(normalized <= 0.50) // Yellow to Orange
{
double t = (normalized - 0.34) / 0.16;
return Color.FromRgb(255, // R: stays 255
(byte)(255 - t * 85), // G: 255 -> 170
0 // B: stays 0
);
}
if(normalized <= 0.67) // Orange to Orange-Red
{
double t = (normalized - 0.50) / 0.17;
return Color.FromRgb(255, // R: stays 255
(byte)(170 - t * 85), // G: 170 -> 85
(byte)(0 + t * 64) // B: 0 -> 64
);
}
if(normalized <= 0.84) // Orange-Red to Red
{
double t = (normalized - 0.67) / 0.17;
return Color.FromRgb(255, // R: stays 255
(byte)(85 - t * 85), // G: 85 -> 0
(byte)(64 + t * 64) // B: 64 -> 128
);
}
else // Red to Dark Red
{
double t = (normalized - 0.84) / 0.16;
return Color.FromRgb((byte)(255 - t * 55), // R: 255 -> 200
0, // G: stays 0
(byte)(128 + t * 127) // B: 128 -> 255
);
}
}
}

View File

@@ -1,491 +0,0 @@
// /***************************************************************************
// Aaru Data Preservation Suite
// ----------------------------------------------------------------------------
//
// Filename : BlockMap.cs
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// Component : GUI custom controls.
//
// --[ Description ] ----------------------------------------------------------
//
// Draws a block map.
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General public License for more details.
//
// You should have received a copy of the GNU General public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2011-2025 Natalia Portillo
// ****************************************************************************/
/* TODO: Doesn't compile with Avalonia 11.0, but it didn't work previously so pending rewriting
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using Aaru.Localization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
using Avalonia.Visuals.Media.Imaging;
using JetBrains.Annotations;
namespace Aaru.Gui.Controls;
// TODO: Partially fill clusters
// TODO: React to size changes
// TODO: Optimize block size to viewport
// TODO: Writing one more than it should
public sealed class BlockMap : ItemsControl
{
const int BLOCK_SIZE = 15;
public static readonly StyledProperty<ulong> BlocksProperty =
AvaloniaProperty.Register<Border, ulong>(nameof(Blocks));
public static readonly StyledProperty<IBrush> SuperFastColorProperty =
AvaloniaProperty.Register<Border, IBrush>(nameof(SuperFastColor), Brushes.LightGreen);
public static readonly StyledProperty<IBrush> FastColorProperty =
AvaloniaProperty.Register<Border, IBrush>(nameof(FastColor), Brushes.Green);
public static readonly StyledProperty<IBrush> AverageColorProperty =
AvaloniaProperty.Register<Border, IBrush>(nameof(AverageColor), Brushes.DarkGreen);
public static readonly StyledProperty<IBrush> SlowColorProperty =
AvaloniaProperty.Register<Border, IBrush>(nameof(SlowColor), Brushes.Yellow);
public static readonly StyledProperty<IBrush> SuperSlowColorProperty =
AvaloniaProperty.Register<Border, IBrush>(nameof(SuperSlowColor), Brushes.Orange);
public static readonly StyledProperty<IBrush> ProblematicColorProperty =
AvaloniaProperty.Register<Border, IBrush>(nameof(ProblematicColor), Brushes.Red);
public static readonly StyledProperty<double> SuperFastMaxTimeProperty =
AvaloniaProperty.Register<Border, double>(nameof(SuperFastMaxTime), 3);
public static readonly StyledProperty<double> FastMaxTimeProperty =
AvaloniaProperty.Register<Border, double>(nameof(FastMaxTime), 10);
public static readonly StyledProperty<double> AverageMaxTimeProperty =
AvaloniaProperty.Register<Border, double>(nameof(AverageMaxTime), 50);
public static readonly StyledProperty<double> SlowMaxTimeProperty =
AvaloniaProperty.Register<Border, double>(nameof(SlowMaxTime), 150);
public static readonly StyledProperty<double> SuperSlowMaxTimeProperty =
AvaloniaProperty.Register<Border, double>(nameof(SuperSlowMaxTime), 500);
RenderTargetBitmap _bitmap;
ulong _clusterSize;
ulong _maxBlocks;
public double SuperFastMaxTime
{
get => GetValue(SuperFastMaxTimeProperty);
set => SetValue(SuperFastMaxTimeProperty, value);
}
public double FastMaxTime
{
get => GetValue(FastMaxTimeProperty);
set => SetValue(FastMaxTimeProperty, value);
}
public double AverageMaxTime
{
get => GetValue(AverageMaxTimeProperty);
set => SetValue(AverageMaxTimeProperty, value);
}
public double SlowMaxTime
{
get => GetValue(SlowMaxTimeProperty);
set => SetValue(SlowMaxTimeProperty, value);
}
public double SuperSlowMaxTime
{
get => GetValue(SuperSlowMaxTimeProperty);
set => SetValue(SuperSlowMaxTimeProperty, value);
}
public IBrush SuperFastColor
{
get => GetValue(SuperFastColorProperty);
set => SetValue(SuperFastColorProperty, value);
}
public IBrush FastColor
{
get => GetValue(FastColorProperty);
set => SetValue(FastColorProperty, value);
}
public IBrush AverageColor
{
get => GetValue(AverageColorProperty);
set => SetValue(AverageColorProperty, value);
}
public IBrush SlowColor
{
get => GetValue(SlowColorProperty);
set => SetValue(SlowColorProperty, value);
}
public IBrush SuperSlowColor
{
get => GetValue(SuperSlowColorProperty);
set => SetValue(SuperSlowColorProperty, value);
}
public IBrush ProblematicColor
{
get => GetValue(ProblematicColorProperty);
set => SetValue(ProblematicColorProperty, value);
}
public ulong Blocks
{
get => GetValue(BlocksProperty);
set => SetValue(BlocksProperty, value);
}
protected override void OnPropertyChanged<T>([NotNull] AvaloniaPropertyChangedEventArgs<T> e)
{
base.OnPropertyChanged(e);
switch(e.Property.Name)
{
case nameof(Blocks):
if(_maxBlocks == 0)
_maxBlocks = (ulong)(Width / BLOCK_SIZE * (Height / BLOCK_SIZE));
if(Blocks > _maxBlocks)
{
_clusterSize = Blocks / _maxBlocks;
if(Blocks % _maxBlocks > 0)
_clusterSize++;
if(Blocks / _clusterSize < _maxBlocks)
{
_maxBlocks = Blocks / _clusterSize;
if(Blocks % _clusterSize > 0)
_maxBlocks++;
}
}
else
{
_clusterSize = 1;
_maxBlocks = Blocks;
}
CreateBitmap();
DrawGrid();
RedrawAll();
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
break;
case nameof(SuperFastMaxTime):
case nameof(FastMaxTime):
case nameof(AverageMaxTime):
case nameof(SlowMaxTime):
case nameof(SuperSlowMaxTime):
case nameof(SuperFastColor):
case nameof(FastColor):
case nameof(AverageColor):
case nameof(SlowColor):
case nameof(SuperSlowColor):
case nameof(ProblematicColor):
CreateBitmap();
DrawGrid();
RedrawAll();
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
break;
}
}
public override void Render(DrawingContext context)
{
if((int?)_bitmap?.Size.Height != (int)Height ||
(int?)_bitmap?.Size.Width != (int)Width)
{
_maxBlocks = (ulong)(Width / BLOCK_SIZE * (Height / BLOCK_SIZE));
CreateBitmap();
}
context.DrawImage(_bitmap, new Rect(0, 0, Width, Height), new Rect(0, 0, Width, Height),
BitmapInterpolationMode.HighQuality);
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
base.Render(context);
}
protected override void ItemsCollectionChanged(object sender, [NotNull] NotifyCollectionChangedEventArgs e)
{
base.ItemsCollectionChanged(sender, e);
switch(e.Action)
{
case NotifyCollectionChangedAction.Add:
case NotifyCollectionChangedAction.Replace:
{
if(e.NewItems is not {} items)
throw new ArgumentException(UI.Invalid_list_of_items);
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
using var ctx = new DrawingContext(ctxi, false);
foreach(object item in items)
{
if(item is not ValueTuple<ulong, double> block)
throw new ArgumentException(UI.Invalid_item_in_list, nameof(Items));
DrawCluster(block.Item1, block.Item2, false, ctx);
}
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
break;
}
case NotifyCollectionChangedAction.Remove:
case NotifyCollectionChangedAction.Move:
{
if(e.NewItems is not {} newItems ||
e.OldItems is not {} oldItems)
throw new ArgumentException(UI.Invalid_list_of_items);
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
using var ctx = new DrawingContext(ctxi, false);
foreach(object item in oldItems)
{
if(item is not ValueTuple<ulong, double> block)
throw new ArgumentException(UI.Invalid_item_in_list, nameof(Items));
DrawCluster(block.Item1, block.Item2, false, ctx);
}
foreach(object item in newItems)
{
if(item is not ValueTuple<ulong, double> block)
throw new ArgumentException(UI.Invalid_item_in_list, nameof(Items));
DrawCluster(block.Item1, block.Item2, false, ctx);
}
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
break;
}
case NotifyCollectionChangedAction.Reset:
CreateBitmap();
DrawGrid();
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
break;
default: throw new ArgumentOutOfRangeException();
}
}
void RedrawAll()
{
if(Items is null)
return;
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
using var ctx = new DrawingContext(ctxi, false);
foreach(object item in Items)
{
if(item is not ValueTuple<ulong, double> block)
throw new ArgumentException(UI.Invalid_item_in_list, nameof(Items));
DrawCluster(block.Item1, block.Item2, false, ctx);
}
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
}
void DrawCluster(ulong block, double duration, bool clear = false, DrawingContext ctx = null)
{
if(double.IsNegative(duration) ||
double.IsInfinity(duration))
throw new ArgumentException(UI.Duration_cannot_be_negative_or_infinite, nameof(duration));
bool newContext = ctx is null;
ulong clustersPerRow = (ulong)Width / BLOCK_SIZE;
ulong cluster = block / _clusterSize;
ulong row = cluster / clustersPerRow;
ulong column = cluster % clustersPerRow;
ulong x = column * BLOCK_SIZE;
ulong y = row * BLOCK_SIZE;
var pen = new Pen(Foreground);
IBrush brush;
if(clear)
brush = Background;
else if(duration < SuperFastMaxTime)
brush = SuperFastColor;
else if(duration >= SuperFastMaxTime &&
duration < FastMaxTime)
brush = FastColor;
else if(duration >= FastMaxTime &&
duration < AverageMaxTime)
brush = AverageColor;
else if(duration >= AverageMaxTime &&
duration < SlowMaxTime)
brush = SlowColor;
else if(duration >= SlowMaxTime &&
duration < SuperSlowMaxTime)
brush = SuperSlowColor;
else if(duration >= SuperSlowMaxTime ||
double.IsNaN(duration))
brush = ProblematicColor;
else
brush = Background;
if(newContext)
{
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
ctx = new DrawingContext(ctxi, false);
}
ctx.FillRectangle(brush, new Rect(x, y, BLOCK_SIZE, BLOCK_SIZE));
ctx.DrawRectangle(pen, new Rect(x, y, BLOCK_SIZE, BLOCK_SIZE));
if(double.IsNaN(duration))
{
ctx.DrawLine(pen, new Point(x, y), new Point(x + BLOCK_SIZE, y + BLOCK_SIZE));
ctx.DrawLine(pen, new Point(x, y + BLOCK_SIZE), new Point(x + BLOCK_SIZE, y));
}
if(newContext)
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
}
protected override void ItemsChanged([NotNull] AvaloniaPropertyChangedEventArgs e)
{
if(e.NewValue != null &&
e.NewValue is not IList<(ulong, double)>)
throw new ArgumentException(UI.Items_must_be_a_IList_ulong_double);
base.ItemsChanged(e);
CreateBitmap();
DrawGrid();
RedrawAll();
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
}
void CreateBitmap()
{
if(_maxBlocks == 0)
_maxBlocks = (ulong)(Width / BLOCK_SIZE * (Height / BLOCK_SIZE));
_bitmap?.Dispose();
_bitmap = new RenderTargetBitmap(new PixelSize((int)Width, (int)Height), new Vector(96, 96));
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
using var ctx = new DrawingContext(ctxi, false);
ctx.FillRectangle(Background, new Rect(0, 0, Width, Height));
}
void DrawGrid()
{
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
using var ctx = new DrawingContext(ctxi, false);
ulong clustersPerRow = (ulong)Width / BLOCK_SIZE;
bool allBlocksDrawn = false;
for(ulong y = 0; y < Height && !allBlocksDrawn; y += BLOCK_SIZE)
{
for(ulong x = 0; x < Width; x += BLOCK_SIZE)
{
ulong currentBlockValue = (y * clustersPerRow / BLOCK_SIZE) + (x / BLOCK_SIZE);
if(currentBlockValue >= _maxBlocks ||
currentBlockValue >= Blocks)
{
allBlocksDrawn = true;
break;
}
ctx.DrawRectangle(new Pen(Foreground), new Rect(x, y, BLOCK_SIZE, BLOCK_SIZE));
}
}
}
void DrawSquares(Color[] colors, int borderWidth, int sideLength)
{
using IDrawingContextImpl ctxi = _bitmap.CreateDrawingContext(null);
using var ctx = new DrawingContext(ctxi, false);
int squareWidth = (sideLength - (2 * borderWidth)) / colors.Length;
int x = 0;
int y = 0;
foreach(Color color in colors)
{
ctx.FillRectangle(new SolidColorBrush(color), new Rect(x, y, squareWidth, squareWidth));
x += squareWidth + (2 * borderWidth);
if(x < sideLength)
continue;
x = 0;
y += squareWidth + (2 * borderWidth);
}
}
protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
{
if(Width < 1 ||
Height < 1 ||
double.IsNaN(Width) ||
double.IsNaN(Height))
{
base.OnAttachedToLogicalTree(e);
return;
}
CreateBitmap();
DrawGrid();
base.OnAttachedToLogicalTree(e);
}
protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
{
_bitmap.Dispose();
_bitmap = null;
base.OnDetachedFromLogicalTree(e);
}
}
*/