From ad52d543e6d3a34edf190646b09b5d8871921e9d Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:48:21 +0100 Subject: [PATCH 1/3] - box selector for time range selection : adapt the selected period - tooltips for data and time in RangeTimeSlicer - vertical bands to display days (even with gaps) --- .../Controls/TimeRangeSlicerControl.axaml.cs | 239 ++++++++++++++++-- 1 file changed, 220 insertions(+), 19 deletions(-) diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs index 036aeaf..a3303ae 100644 --- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -28,11 +28,19 @@ public partial class TimeRangeSlicerControl : UserControl private const double ChartPaddingTop = 16; private const double ChartPaddingBottom = 20; - private enum DragMode { None, MoveRange, DragStart, DragEnd } + // Line points computed in Redraw(), used for hover hit-testing + private Point[] _linePoints = Array.Empty(); + private double _stepX; + + private enum DragMode { None, MoveRange, DragStart, DragEnd, SelectRect } private DragMode _dragMode = DragMode.None; private double _dragOriginX; private double _dragOriginRangeStart; private double _dragOriginRangeEnd; + private double _selectRectOriginX; // canvas-x where drag-select started + private double _selectRectCurrentX; // canvas-x of current pointer during drag-select + + private int _hoveredIndex = -1; // bucket index under the mouse (-1 = none) public event EventHandler? RangeChanged; @@ -103,19 +111,29 @@ public void SetMetric(string metric) private DateTime GetDateTimeAtNorm(double norm) { if (_data.Count == 0) return DateTime.UtcNow; - var first = _data[0].IntervalStartUtc; + var n = _data.Count; + // norm is in bucket-index space: 0 = start of first bucket, 1 = end of last bucket + var pos = norm * n; // fractional bucket index + var idx = (int)Math.Floor(pos); + idx = Math.Clamp(idx, 0, n - 1); + var frac = pos - idx; // fraction within that bucket [0..1) + var bucketStart = _data[idx].IntervalStartUtc; + var ticks = bucketStart.Ticks + (long)(frac * TimeSpan.TicksPerHour); var last = _data[^1].IntervalStartUtc.AddHours(1); - var ticks = first.Ticks + (long)((last.Ticks - first.Ticks) * norm); - return new DateTime(Math.Clamp(ticks, first.Ticks, last.Ticks), DateTimeKind.Utc); + ticks = Math.Clamp(ticks, _data[0].IntervalStartUtc.Ticks, last.Ticks); + return new DateTime(ticks, DateTimeKind.Utc); } private double GetNormFromDateTime(DateTime dt) { if (_data.Count == 0) return 0; - var first = _data[0].IntervalStartUtc; - var last = _data[^1].IntervalStartUtc.AddHours(1); - if (last <= first) return 0; - return Math.Clamp((double)(dt.Ticks - first.Ticks) / (last.Ticks - first.Ticks), 0, 1); + var n = _data.Count; + // Binary-search for the bucket containing dt + var idx = _data.FindIndex(b => b.IntervalStartUtc.AddHours(1) > dt); + if (idx < 0) return 1.0; // dt is past all buckets + if (dt < _data[0].IntervalStartUtc) return 0; + var frac = (double)(dt.Ticks - _data[idx].IntervalStartUtc.Ticks) / TimeSpan.TicksPerHour; + return Math.Clamp((idx + Math.Clamp(frac, 0, 1)) / n, 0, 1); } private double MinNormInterval @@ -123,11 +141,8 @@ private double MinNormInterval get { if (_data.Count == 0) return 0; - var first = _data[0].IntervalStartUtc; - var last = _data[^1].IntervalStartUtc.AddHours(1); - var totalHours = (last - first).TotalHours; - if (totalHours <= 0) return 1; - return Math.Min(MinIntervalHours / totalHours, 1); + var n = _data.Count; + return Math.Min(MinIntervalHours / n, 1); } } @@ -210,6 +225,9 @@ public void Redraw() var y = chartBottom - (values[i] / max) * chartHeight; linePoints.Add(new Point(x, y)); } + // Cache for Y-proximity hit-testing in pointer events + _linePoints = linePoints.ToArray(); + _stepX = stepX; // Area fill var fillBrush = TryFindBrush("SlicerChartFillBrush", new SolidColorBrush(Color.Parse("#332EAEF1"))); @@ -275,6 +293,38 @@ public void Redraw() Canvas.SetTop(metricTb, 2); SlicerCanvas.Children.Add(metricTb); + // ── Day-boundary vertical dashed lines ───────────────────────────── + // Walk buckets and detect when the display-mode date changes. + var dayLineBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#55E4E6EB"))); + for (int di = 1; di < n; di++) + { + var prevDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di - 1].IntervalStartUtc); + var curDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di].IntervalStartUtc); + if (curDisplay.Date != prevDisplay.Date) + { + var xDay = di * stepX; // left edge of the bucket where the new day starts + SlicerCanvas.Children.Add(new Line + { + StartPoint = new Point(xDay, chartTop), + EndPoint = new Point(xDay, chartBottom), + Stroke = dayLineBrush, + StrokeThickness = 1, + StrokeDashArray = new Avalonia.Collections.AvaloniaList { 3, 3 }, + Opacity = 0.5, + }); + var dayLabel = new TextBlock + { + Text = curDisplay.ToString("MM/dd"), + FontSize = 8, + Foreground = dayLineBrush, + Opacity = 0.8, + }; + Canvas.SetLeft(dayLabel, xDay + 2); + Canvas.SetTop(dayLabel, chartTop); + SlicerCanvas.Children.Add(dayLabel); + } + } + // ── Overlays + selection ─────────────────────────────────────────── var overlayBrush = TryFindBrush("SlicerOverlayBrush", new SolidColorBrush(Color.Parse("#99000000"))); var selectedBrush = TryFindBrush("SlicerSelectedBrush", new SolidColorBrush(Color.Parse("#22FFFFFF"))); @@ -344,6 +394,91 @@ public void Redraw() StrokeThickness = 1, Opacity = 0.5, }); + + // ── Drag-select rectangle overlay ────────────────────────────────── + if (_dragMode == DragMode.SelectRect) + { + var rx1 = Math.Min(_selectRectOriginX, _selectRectCurrentX); + var rx2 = Math.Max(_selectRectOriginX, _selectRectCurrentX); + var selRectBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1"))); + // Semi-transparent fill + SlicerCanvas.Children.Add(new Rectangle + { + Width = rx2 - rx1, + Height = h, + Fill = new SolidColorBrush(Color.Parse("#442EAEF1")), + }); + Canvas.SetLeft(SlicerCanvas.Children[^1], rx1); + Canvas.SetTop(SlicerCanvas.Children[^1], 0); + // Border + SlicerCanvas.Children.Add(new Rectangle + { + Width = rx2 - rx1, + Height = h, + Stroke = selRectBrush, + StrokeThickness = 1.5, + Fill = Brushes.Transparent, + }); + Canvas.SetLeft(SlicerCanvas.Children[^1], rx1); + Canvas.SetTop(SlicerCanvas.Children[^1], 0); + } + + // ── Per-bucket hit rectangles: tooltip + hover dot ─────────────────────────── + // Drawn last so they are on top of all overlays and receive pointer events. + var metricLabel = GetMetricLabel(); + var dotBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1"))); + for (int i = 0; i < n; i++) + { + var bucketDisplay = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc); + var bucketDisplayEnd = bucketDisplay.AddHours(1); + var val = values[i]; + var valText = _metric is "executions" ? $"{val:N0}" : $"{val:N2}"; + + TextBlock MakeTipContent() => new TextBlock + { + Text = $"{metricLabel}: {valText}\n" + + $"{bucketDisplay:yyyy-MM-dd HH:mm} \u2013 {bucketDisplayEnd:HH:mm}{TimeDisplayHelper.Suffix}", + FontSize = 12, + FontFamily = new FontFamily("Cascadia Mono,Consolas,monospace"), + Padding = new Thickness(6, 4), + }; + + var hitRect = new Rectangle + { + Width = Math.Max(1, stepX), + Height = chartHeight, + Fill = Brushes.Transparent, + Opacity = 1, + }; + + ToolTip.SetTip(hitRect, MakeTipContent()); + ToolTip.SetShowDelay(hitRect, 300); + ToolTip.SetVerticalOffset(hitRect, 10); + + Canvas.SetLeft(hitRect, i * stepX); + Canvas.SetTop(hitRect, chartTop); + SlicerCanvas.Children.Add(hitRect); + } + + // ── Hover dot ────────────────────────────────────────────────── + var dotIdx = _hoveredIndex; + if (dotIdx >= 0 && dotIdx < n) + { + const double DotR = 7; + var dotX = dotIdx * stepX + stepX / 2; + var dotY = chartBottom - (values[dotIdx] / max) * chartHeight; + var dot = new Ellipse + { + Width = DotR * 2, + Height = DotR * 2, + Fill = dotBrush, + Stroke = TryFindBrush("BackgroundBrush", Brushes.Black), + StrokeThickness = 2, + }; + Canvas.SetLeft(dot, dotX - DotR); + Canvas.SetTop(dot, dotY - DotR); + SlicerCanvas.Children.Add(dot); + } } private void DrawHandle(double x, double canvasHeight, IBrush brush) @@ -382,6 +517,28 @@ private IBrush TryFindBrush(string key, IBrush fallback) return fallback; } + /// + /// Finds the bucket index whose line point is closest horizontally to , + /// or -1 if none qualifies. + /// + private int FindNearestLineIndex(Point pos) + { + if (_linePoints.Length == 0) return -1; + var best = -1; + var bestDist = double.MaxValue; + for (int i = 0; i < _linePoints.Length; i++) + { + var lp = _linePoints[i]; + var dx = Math.Abs(pos.X - lp.X); + if (dx <= _stepX / 2 + 2 && dx < bestDist) + { + bestDist = dx; + best = i; + } + } + return best; + } + // ── Interaction ──────────────────────────────────────────────────────── private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) @@ -424,6 +581,13 @@ private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) e.Handled = true; return; } + + // Click outside selection → start drag-select rectangle + _selectRectOriginX = pos.X; + _selectRectCurrentX = pos.X; + _dragMode = DragMode.SelectRect; + e.Pointer.Capture(SlicerCanvas); + e.Handled = true; } private void Canvas_PointerMoved(object? sender, PointerEventArgs e) @@ -437,7 +601,7 @@ private void Canvas_PointerMoved(object? sender, PointerEventArgs e) if (_dragMode == DragMode.None) { // Update cursor - var selLeft = _rangeStart * w; + var selLeft = _rangeStart * w; var selRight = _rangeEnd * w; if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || @@ -453,6 +617,14 @@ private void Canvas_PointerMoved(object? sender, PointerEventArgs e) { SlicerCanvas.Cursor = Cursor.Default; } + + // Y-proximity hover for dot + var newHover = FindNearestLineIndex(pos); + if (newHover != _hoveredIndex) + { + _hoveredIndex = newHover; + Redraw(); + } return; } @@ -484,6 +656,14 @@ private void Canvas_PointerMoved(object? sender, PointerEventArgs e) _rangeEnd = newStart + span; break; } + case DragMode.SelectRect: + { + _selectRectCurrentX = Math.Clamp(pos.X, 0, w); + UpdateRangeLabel(); + Redraw(); + e.Handled = true; + return; + } } UpdateRangeLabel(); @@ -493,13 +673,33 @@ private void Canvas_PointerMoved(object? sender, PointerEventArgs e) private void Canvas_PointerReleased(object? sender, PointerReleasedEventArgs e) { - if (_dragMode != DragMode.None) + if (_dragMode == DragMode.None) return; + + if (_dragMode == DragMode.SelectRect) { - _dragMode = DragMode.None; - e.Pointer.Capture(null); - FireRangeChanged(); - e.Handled = true; + var w = SlicerBorder.Bounds.Width; + if (w > 0) + { + var rx1 = Math.Min(_selectRectOriginX, _selectRectCurrentX); + var rx2 = Math.Max(_selectRectOriginX, _selectRectCurrentX); + // Only apply if the rectangle has meaningful width (> 4px) + if (rx2 - rx1 > 4) + { + _rangeStart = Math.Clamp(rx1 / w, 0, 1); + _rangeEnd = Math.Clamp(rx2 / w, 0, 1); + // Enforce minimum interval + if (_rangeEnd - _rangeStart < MinNormInterval) + _rangeEnd = Math.Min(1, _rangeStart + MinNormInterval); + } + } } + + _dragMode = DragMode.None; + e.Pointer.Capture(null); + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + e.Handled = true; } private void Canvas_PointerWheelChanged(object? sender, PointerWheelEventArgs e) @@ -570,3 +770,4 @@ public TimeRangeChangedEventArgs(DateTime startUtc, DateTime endUtc) EndUtc = endUtc; } } + From 97c1192e8081e3de97216de2260080b2b21a423a Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:14:37 +0100 Subject: [PATCH 2/3] - add a "move bar" at the top of the selected period - allow to move RangeTimeSlicer period using keyboards arrows left and right --- .../Controls/TimeRangeSlicerControl.axaml.cs | 89 +++++++++++++++++-- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs index a3303ae..8f4261d 100644 --- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -27,6 +27,7 @@ public partial class TimeRangeSlicerControl : UserControl private const double MinIntervalHours = 3; private const double ChartPaddingTop = 16; private const double ChartPaddingBottom = 20; + private const double MoveBarHeight = 14; // height of the draggable move bar at the top of the selection // Line points computed in Redraw(), used for hover hit-testing private Point[] _linePoints = Array.Empty(); @@ -48,6 +49,8 @@ public TimeRangeSlicerControl() { InitializeComponent(); SlicerBorder.SizeChanged += (_, _) => Redraw(); + SlicerCanvas.Focusable = true; + SlicerCanvas.KeyDown += Canvas_KeyDown; } public bool IsExpanded @@ -395,6 +398,34 @@ public void Redraw() Opacity = 0.5, }); + // ── Move bar at the top of the selection ──────────────────────────── + var moveBarBrush = new SolidColorBrush(Color.Parse("#33FFFFFF")); + var moveBar = new Rectangle + { + Width = Math.Max(0, selRight - selLeft), + Height = MoveBarHeight, + Fill = moveBarBrush, + Cursor = new Cursor(StandardCursorType.SizeAll), + }; + Canvas.SetLeft(moveBar, selLeft); + Canvas.SetTop(moveBar, 0); + SlicerCanvas.Children.Add(moveBar); + // Grip dots in the move bar centre + var moveBarMidX = (selLeft + selRight) / 2; + var moveBarMidY = MoveBarHeight / 2; + for (int gi = -1; gi <= 1; gi++) + { + var dot = new Ellipse + { + Width = 3, Height = 3, + Fill = handleBrush, + Opacity = 0.5, + }; + Canvas.SetLeft(dot, moveBarMidX + gi * 6 - 1.5); + Canvas.SetTop(dot, moveBarMidY - 1.5); + SlicerCanvas.Children.Add(dot); + } + // ── Drag-select rectangle overlay ────────────────────────────────── if (_dragMode == DragMode.SelectRect) { @@ -452,8 +483,10 @@ public void Redraw() }; ToolTip.SetTip(hitRect, MakeTipContent()); + ToolTip.SetPlacement(hitRect, PlacementMode.Pointer); + ToolTip.SetHorizontalOffset(hitRect, 0); + ToolTip.SetVerticalOffset(hitRect, -16); ToolTip.SetShowDelay(hitRect, 300); - ToolTip.SetVerticalOffset(hitRect, 10); Canvas.SetLeft(hitRect, i * stepX); Canvas.SetTop(hitRect, chartTop); @@ -544,6 +577,7 @@ private int FindNearestLineIndex(Point pos) private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) { if (_data.Count < 2) return; + SlicerCanvas.Focus(); var pos = e.GetPosition(SlicerCanvas); var w = SlicerBorder.Bounds.Width; if (w <= 0) return; @@ -555,6 +589,15 @@ private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) _dragOriginRangeStart = _rangeStart; _dragOriginRangeEnd = _rangeEnd; + // Click on move bar (top strip of the selection) → move + if (pos.Y <= MoveBarHeight && pos.X >= selLeft && pos.X <= selRight) + { + _dragMode = DragMode.MoveRange; + e.Pointer.Capture(SlicerCanvas); + e.Handled = true; + return; + } + // Check if near left handle if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx) { @@ -573,8 +616,9 @@ private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) return; } - // Check if inside selection → move - if (pos.X >= selLeft && pos.X <= selRight) + // Inside selection: Shift+click → move, plain click → box-select (refine) + if (pos.X >= selLeft && pos.X <= selRight + && e.KeyModifiers.HasFlag(KeyModifiers.Shift)) { _dragMode = DragMode.MoveRange; e.Pointer.Capture(SlicerCanvas); @@ -582,7 +626,7 @@ private void Canvas_PointerPressed(object? sender, PointerPressedEventArgs e) return; } - // Click outside selection → start drag-select rectangle + // Default: start drag-select rectangle (works both inside and outside selection) _selectRectOriginX = pos.X; _selectRectCurrentX = pos.X; _dragMode = DragMode.SelectRect; @@ -604,14 +648,14 @@ private void Canvas_PointerMoved(object? sender, PointerEventArgs e) var selLeft = _rangeStart * w; var selRight = _rangeEnd * w; - if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || - Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + if (pos.Y <= MoveBarHeight && pos.X >= selLeft && pos.X <= selRight) { - SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeWestEast); + SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeAll); } - else if (pos.X >= selLeft && pos.X <= selRight) + else if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || + Math.Abs(pos.X - selRight) <= HandleGripWidthPx) { - SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeAll); + SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeWestEast); } else { @@ -737,6 +781,33 @@ private void Canvas_PointerWheelChanged(object? sender, PointerWheelEventArgs e) e.Handled = true; } + private void Canvas_KeyDown(object? sender, KeyEventArgs e) + { + if (_data.Count < 2) return; + var n = _data.Count; + var step = 1.0 / n; // one bucket + + double delta = e.Key switch + { + Key.Left => -step, + Key.Right => step, + _ => 0, + }; + if (delta == 0) return; + + var span = _rangeEnd - _rangeStart; + var newStart = _rangeStart + delta; + if (newStart < 0) newStart = 0; + if (newStart + span > 1) newStart = 1 - span; + _rangeStart = newStart; + _rangeEnd = newStart + span; + + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + e.Handled = true; + } + private void UpdateRangeLabel() { if (_data.Count == 0) From dbfaf21c7954dc3eb6d4ea258125ffd057eb6ad3 Mon Sep 17 00:00:00 2001 From: rferraton <16419423+rferraton@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:55:41 +0100 Subject: [PATCH 3/3] improve perf and stability --- .../Controls/TimeRangeSlicerControl.axaml.cs | 81 +++++++++++++------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs index 8f4261d..2b4912c 100644 --- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Threading; using PlanViewer.Core.Models; using PlanViewer.Core.Services; @@ -29,6 +30,22 @@ public partial class TimeRangeSlicerControl : UserControl private const double ChartPaddingBottom = 20; private const double MoveBarHeight = 14; // height of the draggable move bar at the top of the selection + // Cached brushes and objects to avoid allocations on every Redraw + private static readonly SolidColorBrush FallbackChartFillBrush = new(Color.Parse("#332EAEF1")); + private static readonly SolidColorBrush FallbackChartLineBrush = new(Color.Parse("#2EAEF1")); + private static readonly SolidColorBrush FallbackLabelBrush = new(Color.Parse("#99E4E6EB")); + private static readonly SolidColorBrush FallbackDayLineBrush = new(Color.Parse("#55E4E6EB")); + private static readonly SolidColorBrush FallbackForegroundBrush = new(Color.Parse("#E4E6EB")); + private static readonly SolidColorBrush FallbackOverlayBrush = new(Color.Parse("#99000000")); + private static readonly SolidColorBrush FallbackSelectedBrush = new(Color.Parse("#22FFFFFF")); + private static readonly SolidColorBrush FallbackHandleBrush = new(Color.Parse("#E4E6EB")); + private static readonly SolidColorBrush MoveBarBrush = new(Color.Parse("#33FFFFFF")); + private static readonly SolidColorBrush SelectRectFillBrush = new(Color.Parse("#442EAEF1")); + private static readonly Avalonia.Collections.AvaloniaList DashArray = new() { 3, 3 }; + private static readonly Cursor CursorSizeAll = new(StandardCursorType.SizeAll); + private static readonly Cursor CursorSizeWE = new(StandardCursorType.SizeWestEast); + private static readonly FontFamily TooltipFont = new("Cascadia Mono,Consolas,monospace"); + // Line points computed in Redraw(), used for hover hit-testing private Point[] _linePoints = Array.Empty(); private double _stepX; @@ -42,6 +59,7 @@ private enum DragMode { None, MoveRange, DragStart, DragEnd, SelectRect } private double _selectRectCurrentX; // canvas-x of current pointer during drag-select private int _hoveredIndex = -1; // bucket index under the mouse (-1 = none) + private DispatcherTimer? _rangeChangedDebounce; public event EventHandler? RangeChanged; @@ -131,10 +149,13 @@ private double GetNormFromDateTime(DateTime dt) { if (_data.Count == 0) return 0; var n = _data.Count; - // Binary-search for the bucket containing dt + if (dt < _data[0].IntervalStartUtc) return 0; + // Find first bucket whose end is past dt var idx = _data.FindIndex(b => b.IntervalStartUtc.AddHours(1) > dt); if (idx < 0) return 1.0; // dt is past all buckets - if (dt < _data[0].IntervalStartUtc) return 0; + // If dt is before this bucket's start (gap), snap to this bucket's start + if (dt < _data[idx].IntervalStartUtc) + return (double)idx / n; var frac = (double)(dt.Ticks - _data[idx].IntervalStartUtc.Ticks) / TimeSpan.TicksPerHour; return Math.Clamp((idx + Math.Clamp(frac, 0, 1)) / n, 0, 1); } @@ -233,7 +254,7 @@ public void Redraw() _stepX = stepX; // Area fill - var fillBrush = TryFindBrush("SlicerChartFillBrush", new SolidColorBrush(Color.Parse("#332EAEF1"))); + var fillBrush = TryFindBrush("SlicerChartFillBrush", FallbackChartFillBrush); var areaGeometry = new StreamGeometry(); using (var ctx = areaGeometry.Open()) { @@ -250,7 +271,7 @@ public void Redraw() }); // Line - var lineBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1"))); + var lineBrush = TryFindBrush("SlicerChartLineBrush", FallbackChartLineBrush); var lineGeometry = new StreamGeometry(); using (var ctx = lineGeometry.Open()) { @@ -267,7 +288,7 @@ public void Redraw() }); // X-axis labels (show a few ticks) - var labelBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#99E4E6EB"))); + var labelBrush = TryFindBrush("SlicerLabelBrush", FallbackLabelBrush); int labelInterval = Math.Max(1, n / 8); for (int i = 0; i < n; i += labelInterval) { @@ -290,7 +311,7 @@ public void Redraw() { Text = GetMetricLabel(), FontSize = 12, - Foreground = TryFindBrush("ForegroundBrush", new SolidColorBrush(Color.Parse("#E4E6EB"))), + Foreground = TryFindBrush("ForegroundBrush", FallbackForegroundBrush), }; Canvas.SetRight(metricTb, 4); Canvas.SetTop(metricTb, 2); @@ -298,7 +319,7 @@ public void Redraw() // ── Day-boundary vertical dashed lines ───────────────────────────── // Walk buckets and detect when the display-mode date changes. - var dayLineBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#55E4E6EB"))); + var dayLineBrush = TryFindBrush("SlicerLabelBrush", FallbackDayLineBrush); for (int di = 1; di < n; di++) { var prevDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di - 1].IntervalStartUtc); @@ -312,7 +333,7 @@ public void Redraw() EndPoint = new Point(xDay, chartBottom), Stroke = dayLineBrush, StrokeThickness = 1, - StrokeDashArray = new Avalonia.Collections.AvaloniaList { 3, 3 }, + StrokeDashArray = DashArray, Opacity = 0.5, }); var dayLabel = new TextBlock @@ -329,9 +350,9 @@ public void Redraw() } // ── Overlays + selection ─────────────────────────────────────────── - var overlayBrush = TryFindBrush("SlicerOverlayBrush", new SolidColorBrush(Color.Parse("#99000000"))); - var selectedBrush = TryFindBrush("SlicerSelectedBrush", new SolidColorBrush(Color.Parse("#22FFFFFF"))); - var handleBrush = TryFindBrush("SlicerHandleBrush", new SolidColorBrush(Color.Parse("#E4E6EB"))); + var overlayBrush = TryFindBrush("SlicerOverlayBrush", FallbackOverlayBrush); + var selectedBrush = TryFindBrush("SlicerSelectedBrush", FallbackSelectedBrush); + var handleBrush = TryFindBrush("SlicerHandleBrush", FallbackHandleBrush); var selLeft = _rangeStart * w; var selRight = _rangeEnd * w; @@ -399,13 +420,12 @@ public void Redraw() }); // ── Move bar at the top of the selection ──────────────────────────── - var moveBarBrush = new SolidColorBrush(Color.Parse("#33FFFFFF")); var moveBar = new Rectangle { Width = Math.Max(0, selRight - selLeft), Height = MoveBarHeight, - Fill = moveBarBrush, - Cursor = new Cursor(StandardCursorType.SizeAll), + Fill = MoveBarBrush, + Cursor = CursorSizeAll, }; Canvas.SetLeft(moveBar, selLeft); Canvas.SetTop(moveBar, 0); @@ -431,13 +451,13 @@ public void Redraw() { var rx1 = Math.Min(_selectRectOriginX, _selectRectCurrentX); var rx2 = Math.Max(_selectRectOriginX, _selectRectCurrentX); - var selRectBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1"))); + var selRectBrush = TryFindBrush("SlicerChartLineBrush", FallbackChartLineBrush); // Semi-transparent fill SlicerCanvas.Children.Add(new Rectangle { Width = rx2 - rx1, Height = h, - Fill = new SolidColorBrush(Color.Parse("#442EAEF1")), + Fill = SelectRectFillBrush, }); Canvas.SetLeft(SlicerCanvas.Children[^1], rx1); Canvas.SetTop(SlicerCanvas.Children[^1], 0); @@ -457,7 +477,7 @@ public void Redraw() // ── Per-bucket hit rectangles: tooltip + hover dot ─────────────────────────── // Drawn last so they are on top of all overlays and receive pointer events. var metricLabel = GetMetricLabel(); - var dotBrush = TryFindBrush("SlicerChartLineBrush", new SolidColorBrush(Color.Parse("#2EAEF1"))); + var dotBrush = TryFindBrush("SlicerChartLineBrush", FallbackChartLineBrush); for (int i = 0; i < n; i++) { var bucketDisplay = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc); @@ -465,12 +485,12 @@ public void Redraw() var val = values[i]; var valText = _metric is "executions" ? $"{val:N0}" : $"{val:N2}"; - TextBlock MakeTipContent() => new TextBlock + var tipContent = new TextBlock { Text = $"{metricLabel}: {valText}\n" + $"{bucketDisplay:yyyy-MM-dd HH:mm} \u2013 {bucketDisplayEnd:HH:mm}{TimeDisplayHelper.Suffix}", FontSize = 12, - FontFamily = new FontFamily("Cascadia Mono,Consolas,monospace"), + FontFamily = TooltipFont, Padding = new Thickness(6, 4), }; @@ -482,7 +502,7 @@ public void Redraw() Opacity = 1, }; - ToolTip.SetTip(hitRect, MakeTipContent()); + ToolTip.SetTip(hitRect, tipContent); ToolTip.SetPlacement(hitRect, PlacementMode.Pointer); ToolTip.SetHorizontalOffset(hitRect, 0); ToolTip.SetVerticalOffset(hitRect, -16); @@ -650,12 +670,12 @@ private void Canvas_PointerMoved(object? sender, PointerEventArgs e) if (pos.Y <= MoveBarHeight && pos.X >= selLeft && pos.X <= selRight) { - SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeAll); + SlicerCanvas.Cursor = CursorSizeAll; } else if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || Math.Abs(pos.X - selRight) <= HandleGripWidthPx) { - SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeWestEast); + SlicerCanvas.Cursor = CursorSizeWE; } else { @@ -804,10 +824,25 @@ private void Canvas_KeyDown(object? sender, KeyEventArgs e) UpdateRangeLabel(); Redraw(); - FireRangeChanged(); + DebouncedFireRangeChanged(); e.Handled = true; } + private void DebouncedFireRangeChanged() + { + _rangeChangedDebounce?.Stop(); + _rangeChangedDebounce = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(250), + }; + _rangeChangedDebounce.Tick += (_, _) => + { + _rangeChangedDebounce.Stop(); + FireRangeChanged(); + }; + _rangeChangedDebounce.Start(); + } + private void UpdateRangeLabel() { if (_data.Count == 0)