using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using LindtLeerformPlugin.Services; namespace LindtLeerformPlugin.Views; /// /// 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. /// public class SplineHistogramEditor : Control { public static readonly StyledProperty HistogramProperty = AvaloniaProperty.Register(nameof(Histogram)); public static readonly StyledProperty CellSplineProperty = AvaloniaProperty.Register( nameof(CellSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); public static readonly StyledProperty AverageSplineProperty = AvaloniaProperty.Register( nameof(AverageSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); public static readonly StyledProperty BinsProperty = AvaloniaProperty.Register(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( 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; /// Raised on pointer release after a spline handle drag has completed. 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); } } }