diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs index 036aeaf..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; @@ -27,12 +28,38 @@ public partial class TimeRangeSlicerControl : UserControl private const double MinIntervalHours = 3; private const double ChartPaddingTop = 16; private const double ChartPaddingBottom = 20; - - private enum DragMode { None, MoveRange, DragStart, DragEnd } + 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; + + 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) + private DispatcherTimer? _rangeChangedDebounce; public event EventHandler? RangeChanged; @@ -40,6 +67,8 @@ public TimeRangeSlicerControl() { InitializeComponent(); SlicerBorder.SizeChanged += (_, _) => Redraw(); + SlicerCanvas.Focusable = true; + SlicerCanvas.KeyDown += Canvas_KeyDown; } public bool IsExpanded @@ -103,19 +132,32 @@ 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; + 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 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); } private double MinNormInterval @@ -123,11 +165,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,9 +249,12 @@ 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"))); + var fillBrush = TryFindBrush("SlicerChartFillBrush", FallbackChartFillBrush); var areaGeometry = new StreamGeometry(); using (var ctx = areaGeometry.Open()) { @@ -229,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()) { @@ -246,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) { @@ -269,16 +311,48 @@ 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); SlicerCanvas.Children.Add(metricTb); + // ── Day-boundary vertical dashed lines ───────────────────────────── + // Walk buckets and detect when the display-mode date changes. + var dayLineBrush = TryFindBrush("SlicerLabelBrush", FallbackDayLineBrush); + 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 = DashArray, + 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"))); - 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; @@ -344,6 +418,120 @@ public void Redraw() StrokeThickness = 1, Opacity = 0.5, }); + + // ── Move bar at the top of the selection ──────────────────────────── + var moveBar = new Rectangle + { + Width = Math.Max(0, selRight - selLeft), + Height = MoveBarHeight, + Fill = MoveBarBrush, + Cursor = CursorSizeAll, + }; + 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) + { + var rx1 = Math.Min(_selectRectOriginX, _selectRectCurrentX); + var rx2 = Math.Max(_selectRectOriginX, _selectRectCurrentX); + var selRectBrush = TryFindBrush("SlicerChartLineBrush", FallbackChartLineBrush); + // Semi-transparent fill + SlicerCanvas.Children.Add(new Rectangle + { + Width = rx2 - rx1, + Height = h, + Fill = SelectRectFillBrush, + }); + 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", FallbackChartLineBrush); + 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}"; + + var tipContent = new TextBlock + { + Text = $"{metricLabel}: {valText}\n" + + $"{bucketDisplay:yyyy-MM-dd HH:mm} \u2013 {bucketDisplayEnd:HH:mm}{TimeDisplayHelper.Suffix}", + FontSize = 12, + FontFamily = TooltipFont, + Padding = new Thickness(6, 4), + }; + + var hitRect = new Rectangle + { + Width = Math.Max(1, stepX), + Height = chartHeight, + Fill = Brushes.Transparent, + Opacity = 1, + }; + + ToolTip.SetTip(hitRect, tipContent); + ToolTip.SetPlacement(hitRect, PlacementMode.Pointer); + ToolTip.SetHorizontalOffset(hitRect, 0); + ToolTip.SetVerticalOffset(hitRect, -16); + ToolTip.SetShowDelay(hitRect, 300); + + 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,11 +570,34 @@ 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) { if (_data.Count < 2) return; + SlicerCanvas.Focus(); var pos = e.GetPosition(SlicerCanvas); var w = SlicerBorder.Bounds.Width; if (w <= 0) return; @@ -398,6 +609,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) { @@ -416,14 +636,22 @@ 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); e.Handled = true; return; } + + // Default: start drag-select rectangle (works both inside and outside selection) + _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,22 +665,30 @@ 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 || - Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + if (pos.Y <= MoveBarHeight && pos.X >= selLeft && pos.X <= selRight) { - SlicerCanvas.Cursor = new Cursor(StandardCursorType.SizeWestEast); + SlicerCanvas.Cursor = CursorSizeAll; } - 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 = CursorSizeWE; } else { SlicerCanvas.Cursor = Cursor.Default; } + + // Y-proximity hover for dot + var newHover = FindNearestLineIndex(pos); + if (newHover != _hoveredIndex) + { + _hoveredIndex = newHover; + Redraw(); + } return; } @@ -484,6 +720,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 +737,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) @@ -537,6 +801,48 @@ 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(); + 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) @@ -570,3 +876,4 @@ public TimeRangeChangedEventArgs(DateTime startUtc, DateTime endUtc) EndUtc = endUtc; } } +