306 lines
11 KiB
C#
306 lines
11 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Media;
|
|
using LindtLeerformPlugin.Services;
|
|
|
|
namespace LindtLeerformPlugin.Views;
|
|
|
|
/// <summary>
|
|
/// Custom Avalonia control that draws a histogram as colored bars and overlays an editable
|
|
/// average spline (faint dashed gray) plus an optional editable cell spline (bright orange).
|
|
/// Five control points per spline; X positions are fixed and only Y is dragged.
|
|
/// </summary>
|
|
public class SplineHistogramEditor : Control
|
|
{
|
|
public static readonly StyledProperty<float[]?> HistogramProperty =
|
|
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(nameof(Histogram));
|
|
|
|
public static readonly StyledProperty<float[]?> CellSplineProperty =
|
|
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(
|
|
nameof(CellSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
|
|
|
|
public static readonly StyledProperty<float[]?> AverageSplineProperty =
|
|
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(
|
|
nameof(AverageSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
|
|
|
|
public static readonly StyledProperty<int[]?> BinsProperty =
|
|
AvaloniaProperty.Register<SplineHistogramEditor, int[]?>(nameof(Bins));
|
|
|
|
public float[]? Histogram
|
|
{
|
|
get => GetValue(HistogramProperty);
|
|
set => SetValue(HistogramProperty, value);
|
|
}
|
|
|
|
public float[]? CellSpline
|
|
{
|
|
get => GetValue(CellSplineProperty);
|
|
set => SetValue(CellSplineProperty, value);
|
|
}
|
|
|
|
public float[]? AverageSpline
|
|
{
|
|
get => GetValue(AverageSplineProperty);
|
|
set => SetValue(AverageSplineProperty, value);
|
|
}
|
|
|
|
public int[]? Bins
|
|
{
|
|
get => GetValue(BinsProperty);
|
|
set => SetValue(BinsProperty, value);
|
|
}
|
|
|
|
private const double LeftPad = 12;
|
|
private const double RightPad = 12;
|
|
private const double TopPad = 12;
|
|
private const double BottomPad = 18;
|
|
private const double HitRadius = 12;
|
|
private const double HandleRadius = 6;
|
|
|
|
private static readonly IBrush BackgroundBrush = new SolidColorBrush(Color.FromRgb(20, 20, 20));
|
|
private static readonly IPen AxisPen = new Pen(new SolidColorBrush(Color.FromRgb(80, 80, 80)), 1);
|
|
private static readonly IPen AverageSplinePen = new Pen(
|
|
new SolidColorBrush(Color.FromRgb(170, 170, 170)), 2,
|
|
dashStyle: new DashStyle(new double[] { 4, 4 }, 0));
|
|
private static readonly IBrush AverageHandleFill = new SolidColorBrush(Color.FromRgb(220, 220, 220));
|
|
private static readonly IPen AverageHandleStroke = new Pen(new SolidColorBrush(Color.FromRgb(80, 80, 80)), 1);
|
|
private static readonly IPen CellSplinePen = new Pen(new SolidColorBrush(Color.FromRgb(255, 140, 0)), 2.5);
|
|
private static readonly IBrush CellHandleFill = new SolidColorBrush(Color.FromRgb(255, 140, 0));
|
|
private static readonly IPen CellHandleStroke = new Pen(Brushes.Black, 1);
|
|
|
|
static SplineHistogramEditor()
|
|
{
|
|
AffectsRender<SplineHistogramEditor>(
|
|
HistogramProperty, CellSplineProperty, AverageSplineProperty, BinsProperty);
|
|
}
|
|
|
|
public SplineHistogramEditor()
|
|
{
|
|
// Render() fills the bounds, and Control hit-tests its full Bounds rectangle by
|
|
// default, so we just need to be focusable for pointer capture during drag.
|
|
Focusable = true;
|
|
}
|
|
|
|
// Cached handle pixel positions, refreshed at every Render(). Indexed [0..4].
|
|
private readonly Point[] _avgHandlePixels = new Point[SplineCurve.KnotCount];
|
|
private readonly Point[] _cellHandlePixels = new Point[SplineCurve.KnotCount];
|
|
|
|
private SplineKind _draggingKind = SplineKind.None;
|
|
private int _draggingIndex = -1;
|
|
|
|
/// <summary>Raised on pointer release after a spline handle drag has completed.</summary>
|
|
public event EventHandler? SplineDragCompleted;
|
|
|
|
private enum SplineKind { None, Average, Cell }
|
|
|
|
public override void Render(DrawingContext ctx)
|
|
{
|
|
var bounds = new Rect(Bounds.Size);
|
|
ctx.FillRectangle(BackgroundBrush, bounds);
|
|
|
|
var plotRect = new Rect(
|
|
LeftPad, TopPad,
|
|
Math.Max(1, Bounds.Width - LeftPad - RightPad),
|
|
Math.Max(1, Bounds.Height - TopPad - BottomPad));
|
|
|
|
DrawAxes(ctx, plotRect);
|
|
|
|
var hist = Histogram;
|
|
var bins = Bins;
|
|
var binCount = (hist?.Length) ?? (bins is { Length: 3 } ? bins[0] * bins[1] * bins[2] : 0);
|
|
|
|
// Fixed [0, 1] Y axis — no auto-zoom. Bins and splines are L1-normalised so 1.0
|
|
// is the natural ceiling and the user always sees an absolute scale.
|
|
const double displayMax = 1.0;
|
|
|
|
if (hist != null && hist.Length > 0 && bins is { Length: 3 })
|
|
DrawBars(ctx, plotRect, hist, bins, displayMax);
|
|
|
|
if (binCount <= 0)
|
|
binCount = 125; // safe default for spline rendering when histogram is missing
|
|
|
|
DrawSpline(ctx, plotRect, AverageSpline, AverageSplinePen, displayMax, binCount,
|
|
_avgHandlePixels, AverageHandleFill, AverageHandleStroke, drawHandles: AverageSpline is { Length: SplineCurve.KnotCount });
|
|
DrawSpline(ctx, plotRect, CellSpline, CellSplinePen, displayMax, binCount,
|
|
_cellHandlePixels, CellHandleFill, CellHandleStroke, drawHandles: CellSpline is { Length: SplineCurve.KnotCount });
|
|
}
|
|
|
|
private static void DrawAxes(DrawingContext ctx, Rect plot)
|
|
{
|
|
ctx.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
|
ctx.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
|
|
}
|
|
|
|
private static void DrawBars(DrawingContext ctx, Rect plot, float[] hist, int[] bins, double displayMax)
|
|
{
|
|
var totalBins = bins[0] * bins[1] * bins[2];
|
|
if (totalBins <= 0) return;
|
|
|
|
var barWidth = plot.Width / totalBins;
|
|
for (var i = 0; i < totalBins && i < hist.Length; i++)
|
|
{
|
|
var binB = i / (bins[1] * bins[2]);
|
|
var binG = (i / bins[2]) % bins[1];
|
|
var binR = i % bins[2];
|
|
|
|
var b = (byte)Math.Min(255, (int)((binB + 0.5) * 256 / bins[0]));
|
|
var g = (byte)Math.Min(255, (int)((binG + 0.5) * 256 / bins[1]));
|
|
var r = (byte)Math.Min(255, (int)((binR + 0.5) * 256 / bins[2]));
|
|
var brush = new SolidColorBrush(Color.FromRgb(r, g, b));
|
|
|
|
var v = hist[i];
|
|
if (v < 0) v = 0;
|
|
var h = v / displayMax * plot.Height;
|
|
if (h < 0) h = 0;
|
|
if (h > plot.Height) h = plot.Height;
|
|
|
|
var x = plot.Left + i * barWidth;
|
|
var y = plot.Bottom - h;
|
|
ctx.FillRectangle(brush, new Rect(x, y, Math.Max(1, barWidth), h));
|
|
}
|
|
}
|
|
|
|
private void DrawSpline(DrawingContext ctx, Rect plot, float[]? knots, IPen curvePen,
|
|
double displayMax, int binCount, Point[] handleCache, IBrush handleFill, IPen handleStroke,
|
|
bool drawHandles)
|
|
{
|
|
if (knots == null || knots.Length < 2 || displayMax <= 0)
|
|
{
|
|
for (var k = 0; k < handleCache.Length; k++) handleCache[k] = new Point(double.NaN, double.NaN);
|
|
return;
|
|
}
|
|
|
|
// Sample the spline along the plot width and stroke a polyline.
|
|
var steps = Math.Max(64, (int)plot.Width);
|
|
var geometry = new StreamGeometry();
|
|
using (var sgc = geometry.Open())
|
|
{
|
|
for (var s = 0; s <= steps; s++)
|
|
{
|
|
var t = (double)s / steps;
|
|
var y = SplineCurve.Evaluate(knots, t);
|
|
var px = plot.Left + t * plot.Width;
|
|
var py = plot.Bottom - Math.Clamp(y / displayMax, 0, 1) * plot.Height;
|
|
var p = new Point(px, py);
|
|
if (s == 0) sgc.BeginFigure(p, false);
|
|
else sgc.LineTo(p);
|
|
}
|
|
sgc.EndFigure(false);
|
|
}
|
|
ctx.DrawGeometry(null, curvePen, geometry);
|
|
|
|
// Cache and draw handles at the knot positions.
|
|
if (knots.Length <= handleCache.Length)
|
|
{
|
|
for (var k = 0; k < knots.Length; k++)
|
|
{
|
|
var t = knots.Length == 1 ? 0.0 : (double)k / (knots.Length - 1);
|
|
var px = plot.Left + t * plot.Width;
|
|
var py = plot.Bottom - Math.Clamp(knots[k] / displayMax, 0, 1) * plot.Height;
|
|
handleCache[k] = new Point(px, py);
|
|
|
|
if (drawHandles)
|
|
{
|
|
ctx.DrawEllipse(handleFill, handleStroke, handleCache[k], HandleRadius, HandleRadius);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
|
{
|
|
base.OnPointerPressed(e);
|
|
var props = e.GetCurrentPoint(this).Properties;
|
|
if (!props.IsLeftButtonPressed) return;
|
|
|
|
var pos = e.GetPosition(this);
|
|
|
|
// Cell handles take priority over average handles when both overlap.
|
|
var hit = HitTest(pos, _cellHandlePixels, requireCellSpline: true);
|
|
if (hit >= 0)
|
|
{
|
|
_draggingKind = SplineKind.Cell;
|
|
_draggingIndex = hit;
|
|
}
|
|
else
|
|
{
|
|
hit = HitTest(pos, _avgHandlePixels, requireCellSpline: false);
|
|
if (hit < 0) return;
|
|
_draggingKind = SplineKind.Average;
|
|
_draggingIndex = hit;
|
|
}
|
|
|
|
e.Pointer.Capture(this);
|
|
ApplyDrag(pos);
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnPointerMoved(PointerEventArgs e)
|
|
{
|
|
base.OnPointerMoved(e);
|
|
if (_draggingKind == SplineKind.None) return;
|
|
var pos = e.GetPosition(this);
|
|
ApplyDrag(pos);
|
|
e.Handled = true;
|
|
}
|
|
|
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
|
{
|
|
base.OnPointerReleased(e);
|
|
if (_draggingKind == SplineKind.None) return;
|
|
_draggingKind = SplineKind.None;
|
|
_draggingIndex = -1;
|
|
if (e.Pointer.Captured == this)
|
|
e.Pointer.Capture(null);
|
|
e.Handled = true;
|
|
SplineDragCompleted?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private int HitTest(Point pos, Point[] handles, bool requireCellSpline)
|
|
{
|
|
if (requireCellSpline && CellSpline is not { Length: SplineCurve.KnotCount }) return -1;
|
|
|
|
for (var i = 0; i < handles.Length; i++)
|
|
{
|
|
var h = handles[i];
|
|
if (double.IsNaN(h.X)) continue;
|
|
var dx = pos.X - h.X;
|
|
var dy = pos.Y - h.Y;
|
|
if (dx * dx + dy * dy <= HitRadius * HitRadius)
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
private void ApplyDrag(Point pos)
|
|
{
|
|
if (_draggingIndex < 0) return;
|
|
|
|
var plotRect = new Rect(
|
|
LeftPad, TopPad,
|
|
Math.Max(1, Bounds.Width - LeftPad - RightPad),
|
|
Math.Max(1, Bounds.Height - TopPad - BottomPad));
|
|
|
|
var rel = (plotRect.Bottom - pos.Y) / plotRect.Height;
|
|
var newY = (float)Math.Clamp(rel, 0.0, 1.0);
|
|
|
|
if (_draggingKind == SplineKind.Average)
|
|
{
|
|
var current = AverageSpline;
|
|
if (current is not { Length: SplineCurve.KnotCount }) return;
|
|
var copy = (float[])current.Clone();
|
|
copy[_draggingIndex] = newY;
|
|
SetCurrentValue(AverageSplineProperty, copy);
|
|
}
|
|
else if (_draggingKind == SplineKind.Cell)
|
|
{
|
|
var current = CellSpline;
|
|
if (current is not { Length: SplineCurve.KnotCount }) return;
|
|
var copy = (float[])current.Clone();
|
|
copy[_draggingIndex] = newY;
|
|
SetCurrentValue(CellSplineProperty, copy);
|
|
}
|
|
}
|
|
}
|