mirror of
https://github.com/8damon/Blackbird-Platform
synced 2026-06-21 13:41:12 +00:00
486 lines
18 KiB
C#
486 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Controls.Primitives;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
|
|
namespace BlackbirdInterface
|
|
{
|
|
public enum SeriesScale
|
|
{
|
|
Percent,
|
|
AutoToViewMax
|
|
}
|
|
|
|
public enum ChartValueFormat
|
|
{
|
|
Auto,
|
|
Percent,
|
|
Bytes,
|
|
BytesPerSecond,
|
|
CountPerSecond,
|
|
Raw
|
|
}
|
|
|
|
public sealed class ChartSeries
|
|
{
|
|
public string Name { get; }
|
|
public Brush Stroke { get; }
|
|
public SeriesScale Scale { get; }
|
|
public ChartValueFormat ValueFormat { get; }
|
|
public double SmoothingAlpha { get; }
|
|
public Func<PerformanceSample, double> Selector { get; }
|
|
|
|
public ChartSeries(string name, Brush stroke, SeriesScale scale, Func<PerformanceSample, double> selector,
|
|
ChartValueFormat valueFormat = ChartValueFormat.Auto, double smoothingAlpha = 0)
|
|
{
|
|
Name = name;
|
|
Stroke = stroke;
|
|
Scale = scale;
|
|
ValueFormat = valueFormat;
|
|
SmoothingAlpha = Math.Clamp(smoothingAlpha, 0d, 1d);
|
|
Selector = selector;
|
|
}
|
|
}
|
|
|
|
public sealed class PerformanceChartControl : FrameworkElement
|
|
{
|
|
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
|
|
nameof(Title), typeof(string), typeof(PerformanceChartControl),
|
|
new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender));
|
|
|
|
public string Title
|
|
{
|
|
get => (string)GetValue(TitleProperty);
|
|
set => SetValue(TitleProperty, value);
|
|
}
|
|
|
|
private DateTime _viewStartUtc;
|
|
private DateTime _viewEndUtc;
|
|
private readonly List<PerformanceSample> _samples = new();
|
|
private ChartSeries[] _series = Array.Empty<ChartSeries>();
|
|
private double _smoothedAutoScaleMax = 1.0;
|
|
private bool _hasMouse;
|
|
private Point _mouse;
|
|
private readonly ToolTip _tip = new();
|
|
|
|
public PerformanceChartControl()
|
|
{
|
|
ClipToBounds = true;
|
|
ToolTip = _tip;
|
|
_tip.Placement = PlacementMode.RelativePoint;
|
|
_tip.PlacementTarget = this;
|
|
_tip.HorizontalOffset = 12;
|
|
_tip.VerticalOffset = 12;
|
|
_tip.StaysOpen = true;
|
|
}
|
|
|
|
public void SetSeries(IEnumerable<ChartSeries> series)
|
|
{
|
|
_series = series.ToArray();
|
|
InvalidateVisual();
|
|
}
|
|
|
|
public void SetView(DateTime viewStartUtc, DateTime viewEndUtc)
|
|
{
|
|
_viewStartUtc = viewStartUtc;
|
|
_viewEndUtc = viewEndUtc;
|
|
InvalidateVisual();
|
|
}
|
|
|
|
public void PushSample(PerformanceSample s)
|
|
{
|
|
_samples.Add(s);
|
|
// Keep a rolling window
|
|
if (_samples.Count > 4000)
|
|
_samples.RemoveRange(0, _samples.Count - 4000);
|
|
|
|
InvalidateVisual();
|
|
}
|
|
|
|
public void ClearSamples()
|
|
{
|
|
_samples.Clear();
|
|
InvalidateVisual();
|
|
}
|
|
|
|
public void SetSamples(IEnumerable<PerformanceSample> samples)
|
|
{
|
|
_samples.Clear();
|
|
_samples.AddRange(samples);
|
|
if (_samples.Count > 4000)
|
|
_samples.RemoveRange(0, _samples.Count - 4000);
|
|
InvalidateVisual();
|
|
}
|
|
|
|
protected override void OnMouseMove(MouseEventArgs e)
|
|
{
|
|
_hasMouse = true;
|
|
_mouse = e.GetPosition(this);
|
|
InvalidateVisual();
|
|
base.OnMouseMove(e);
|
|
}
|
|
|
|
protected override void OnMouseLeave(MouseEventArgs e)
|
|
{
|
|
_hasMouse = false;
|
|
_tip.IsOpen = false;
|
|
InvalidateVisual();
|
|
base.OnMouseLeave(e);
|
|
}
|
|
|
|
protected override void OnRender(DrawingContext dc)
|
|
{
|
|
base.OnRender(dc);
|
|
|
|
var w = ActualWidth;
|
|
var h = ActualHeight;
|
|
if (w < 50 || h < 50)
|
|
return;
|
|
|
|
// Frame
|
|
dc.DrawRectangle(UiPalette.SurfaceBrush, new Pen(UiPalette.BorderBrush, 1), new Rect(0, 0, w, h));
|
|
|
|
// Layout
|
|
double padL = 92;
|
|
double padR = 14;
|
|
double padT = 48;
|
|
double padB = 22;
|
|
|
|
var plot = new Rect(padL, padT, Math.Max(1, w - padL - padR), Math.Max(1, h - padT - padB));
|
|
|
|
// Title
|
|
DrawText(dc, Title, 12, FontWeights.SemiBold, UiPalette.TextBrush, new Point(8, 5));
|
|
DrawLegend(dc, new Rect(padL, 3, Math.Max(1, w - padL - padR), 18));
|
|
|
|
DateTime renderStartUtc = _viewStartUtc;
|
|
DateTime renderEndUtc = _viewEndUtc;
|
|
if (renderEndUtc <= renderStartUtc)
|
|
{
|
|
renderEndUtc = renderStartUtc + TimeSpan.FromSeconds(1);
|
|
}
|
|
AdjustRightAnchoredLiveWindow(ref renderStartUtc, ref renderEndUtc);
|
|
|
|
// Filter samples within effective view
|
|
var viewSamples =
|
|
_samples.Where(s => s.TimestampUtc >= renderStartUtc && s.TimestampUtc <= renderEndUtc).ToList();
|
|
if (viewSamples.Count < 2 || _series.Length == 0)
|
|
{
|
|
DrawAxes(dc, plot, renderStartUtc, renderEndUtc, 100.0, ChartValueFormat.Percent);
|
|
DrawText(dc, "NO DATA", 18, FontWeights.Bold, UiPalette.MutedTextBrush,
|
|
new Point(plot.Left + (plot.Width * 0.5) - 40, plot.Top + (plot.Height * 0.5) - 10));
|
|
_tip.IsOpen = false;
|
|
return;
|
|
}
|
|
|
|
var seriesValues =
|
|
new Dictionary<ChartSeries, List<(PerformanceSample sample, double value)>>(_series.Length);
|
|
foreach (var ser in _series)
|
|
{
|
|
bool hasSmoothed = false;
|
|
double smoothed = 0;
|
|
var values = new List<(PerformanceSample sample, double value)>(viewSamples.Count);
|
|
foreach (var sample in viewSamples)
|
|
{
|
|
double raw = ser.Selector(sample);
|
|
double value = ApplySmoothing(raw, ser.SmoothingAlpha, ref hasSmoothed, ref smoothed);
|
|
values.Add((sample, value));
|
|
}
|
|
seriesValues[ser] = values;
|
|
}
|
|
|
|
bool hasAutoScale = _series.Any(s => s.Scale == SeriesScale.AutoToViewMax);
|
|
double autoScaleMax = 1.0;
|
|
ChartValueFormat autoScaleFormat = ChartValueFormat.Raw;
|
|
if (hasAutoScale)
|
|
{
|
|
foreach (var ser in _series.Where(s => s.Scale == SeriesScale.AutoToViewMax))
|
|
{
|
|
if (autoScaleFormat == ChartValueFormat.Raw || autoScaleFormat == ChartValueFormat.Auto)
|
|
autoScaleFormat = ser.ValueFormat;
|
|
|
|
foreach (var point in seriesValues[ser])
|
|
{
|
|
if (point.value > autoScaleMax)
|
|
autoScaleMax = point.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
double yAxisMax;
|
|
if (hasAutoScale)
|
|
{
|
|
double target = Math.Max(1.0, autoScaleMax);
|
|
if (target >= _smoothedAutoScaleMax)
|
|
{
|
|
// rise quickly to new peaks
|
|
_smoothedAutoScaleMax = (_smoothedAutoScaleMax * 0.35) + (target * 0.65);
|
|
}
|
|
else
|
|
{
|
|
// decay slowly to avoid axis jitter
|
|
_smoothedAutoScaleMax = (_smoothedAutoScaleMax * 0.90) + (target * 0.10);
|
|
}
|
|
yAxisMax = Math.Max(1.0, _smoothedAutoScaleMax);
|
|
}
|
|
else
|
|
{
|
|
_smoothedAutoScaleMax = 1.0;
|
|
yAxisMax = 100.0;
|
|
}
|
|
DrawAxes(dc, plot, renderStartUtc, renderEndUtc, yAxisMax,
|
|
hasAutoScale ? autoScaleFormat : ChartValueFormat.Percent);
|
|
|
|
(ChartSeries ser, PerformanceSample sample, Point point, double raw)? hover = null;
|
|
double hoverDist = double.MaxValue;
|
|
|
|
// Draw series (clipped to plot bounds)
|
|
double viewSeconds = (renderEndUtc - renderStartUtc).TotalSeconds;
|
|
if (viewSeconds <= 0)
|
|
viewSeconds = 1;
|
|
|
|
dc.PushClip(new RectangleGeometry(plot));
|
|
foreach (var ser in _series)
|
|
{
|
|
var pen = new Pen(ser.Stroke, 1.15);
|
|
pen.Freeze();
|
|
|
|
var geo = new StreamGeometry();
|
|
using (var g = geo.Open())
|
|
{
|
|
bool started = false;
|
|
if (viewSamples[0].TimestampUtc > renderStartUtc)
|
|
{
|
|
var first = seriesValues[ser][0];
|
|
double raw0 = first.value;
|
|
double scaled0 = ser.Scale == SeriesScale.Percent ? Clamp(raw0, 0, 100)
|
|
: Clamp(raw0 / yAxisMax * 100.0, 0, 100);
|
|
double y0 = plot.Bottom - (scaled0 / 100.0) * plot.Height;
|
|
g.BeginFigure(new Point(plot.Left, y0), false, false);
|
|
started = true;
|
|
}
|
|
|
|
foreach (var point in seriesValues[ser])
|
|
{
|
|
double raw = point.value;
|
|
double scaledPercent = ser.Scale == SeriesScale.Percent ? Clamp(raw, 0, 100)
|
|
: Clamp(raw / yAxisMax * 100.0, 0, 100);
|
|
|
|
double xNorm = (point.sample.TimestampUtc - renderStartUtc).TotalSeconds / viewSeconds; // 0..1
|
|
double x = plot.Left + xNorm * plot.Width;
|
|
double y = plot.Bottom - (scaledPercent / 100.0) * plot.Height;
|
|
|
|
if (_hasMouse && _mouse.X >= plot.Left && _mouse.X <= plot.Right && _mouse.Y >= plot.Top &&
|
|
_mouse.Y <= plot.Bottom)
|
|
{
|
|
double dist = Math.Abs(_mouse.X - x) + (Math.Abs(_mouse.Y - y) * 0.35);
|
|
if (dist < hoverDist)
|
|
{
|
|
hoverDist = dist;
|
|
hover = (ser, point.sample, new Point(x, y), raw);
|
|
}
|
|
}
|
|
|
|
if (!started)
|
|
{
|
|
g.BeginFigure(new Point(x, y), false, false);
|
|
started = true;
|
|
}
|
|
else
|
|
{
|
|
g.LineTo(new Point(x, y), true, false);
|
|
}
|
|
}
|
|
}
|
|
geo.Freeze();
|
|
|
|
dc.DrawGeometry(null, pen, geo);
|
|
}
|
|
dc.Pop();
|
|
|
|
if (hover != null)
|
|
{
|
|
var cross = new Pen(UiPalette.GridStrongBrush, 1);
|
|
cross.Freeze();
|
|
dc.DrawLine(cross, new Point(hover.Value.point.X, plot.Top),
|
|
new Point(hover.Value.point.X, plot.Bottom));
|
|
|
|
var markerBrush =
|
|
new SolidColorBrush(Color.FromArgb(220, UiPalette.Text.R, UiPalette.Text.G, UiPalette.Text.B));
|
|
markerBrush.Freeze();
|
|
dc.DrawEllipse(markerBrush, null, hover.Value.point, 3.6, 3.6);
|
|
|
|
_tip.Content = $"{hover.Value.ser.Name}\n" + $"{hover.Value.sample.TimestampUtc:HH:mm:ss.fff}Z\n" +
|
|
$"{FormatValue(hover.Value.ser, hover.Value.raw)}";
|
|
_tip.PlacementRectangle = new Rect(_mouse.X, _mouse.Y, 0, 0);
|
|
_tip.IsOpen = true;
|
|
}
|
|
else
|
|
{
|
|
_tip.IsOpen = false;
|
|
}
|
|
}
|
|
|
|
private void DrawAxes(DrawingContext dc, Rect plot, DateTime viewStartUtc, DateTime viewEndUtc,
|
|
double axisMaxValue, ChartValueFormat valueFormat)
|
|
{
|
|
var axisPen = new Pen(UiPalette.GridStrongBrush, 1);
|
|
axisPen.Freeze();
|
|
|
|
// Outer plot border
|
|
dc.DrawRectangle(null, axisPen, plot);
|
|
|
|
// Y ticks
|
|
int yTicks = plot.Height < 95 ? 3 : 5;
|
|
for (int i = 0; i <= yTicks; i++)
|
|
{
|
|
double pct = i * (100.0 / yTicks);
|
|
double y = plot.Bottom - (pct / 100.0) * plot.Height;
|
|
double axisValue = axisMaxValue * (i / (double)yTicks);
|
|
|
|
dc.DrawLine(new Pen(UiPalette.GridBrush, 1), new Point(plot.Left, y), new Point(plot.Right, y));
|
|
DrawText(dc, FormatAxisValue(axisValue, valueFormat), 10, FontWeights.Normal, UiPalette.MutedTextBrush,
|
|
new Point(6, y - 7));
|
|
}
|
|
|
|
// X ticks
|
|
var total = (viewEndUtc - viewStartUtc).TotalSeconds;
|
|
if (total <= 0)
|
|
total = 1;
|
|
|
|
int ticks = plot.Width < 280 ? 3 : 5;
|
|
for (int i = 0; i <= ticks; i++)
|
|
{
|
|
double xNorm = i / (double)ticks;
|
|
double x = plot.Left + xNorm * plot.Width;
|
|
dc.DrawLine(new Pen(UiPalette.GridBrush, 1), new Point(x, plot.Top), new Point(x, plot.Bottom));
|
|
|
|
var t = viewStartUtc.AddSeconds(total * xNorm);
|
|
double tx = Math.Max(plot.Left, Math.Min(plot.Right - 42, x - 22));
|
|
string text = plot.Width < 280 ? t.ToString("HH:mm", CultureInfo.InvariantCulture)
|
|
: t.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
|
|
DrawText(dc, text, 10, FontWeights.Normal, UiPalette.MutedTextBrush, new Point(tx, plot.Bottom + 2));
|
|
}
|
|
}
|
|
|
|
private void DrawLegend(DrawingContext dc, Rect legendArea)
|
|
{
|
|
if (_series.Length == 0)
|
|
return;
|
|
|
|
double x = legendArea.Left + 4;
|
|
double y = legendArea.Top + 2;
|
|
double maxX = legendArea.Right - 90;
|
|
|
|
foreach (var ser in _series)
|
|
{
|
|
if (x > maxX)
|
|
break;
|
|
|
|
dc.DrawRectangle(ser.Stroke, null, new Rect(x, y + 3, 10, 10));
|
|
DrawText(dc, ser.Name, 10, FontWeights.Normal, UiPalette.MutedTextBrush, new Point(x + 14, y));
|
|
x += 14 + Math.Max(52, (ser.Name.Length * 6.8));
|
|
}
|
|
}
|
|
|
|
private static void DrawText(DrawingContext dc, string text, double size, FontWeight weight, Brush brush,
|
|
Point p)
|
|
{
|
|
var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
|
new Typeface(new FontFamily("Segoe UI Variable Text, Segoe UI, Aptos"),
|
|
FontStyles.Normal, weight, FontStretches.Normal),
|
|
size, brush, 1.0);
|
|
|
|
dc.DrawText(ft, p);
|
|
}
|
|
|
|
private static string FormatValue(ChartSeries ser, double raw)
|
|
{
|
|
return FormatAxisValue(raw, ser.ValueFormat);
|
|
}
|
|
|
|
private static string FormatAxisValue(double value, ChartValueFormat format)
|
|
{
|
|
return format switch { ChartValueFormat.Percent =>
|
|
value.ToString("0.##", CultureInfo.InvariantCulture) + "%",
|
|
ChartValueFormat.Bytes => FormatBytes(value),
|
|
ChartValueFormat.BytesPerSecond => FormatBytes(value) + "/s",
|
|
ChartValueFormat.CountPerSecond =>
|
|
value.ToString("0.##", CultureInfo.InvariantCulture) + "/s",
|
|
ChartValueFormat.Raw => value.ToString("0.##", CultureInfo.InvariantCulture),
|
|
_ => value.ToString("0.##", CultureInfo.InvariantCulture) };
|
|
}
|
|
|
|
private static string FormatBytes(double value)
|
|
{
|
|
if (value >= 1024d * 1024 * 1024)
|
|
return (value / (1024d * 1024 * 1024)).ToString("0.##", CultureInfo.InvariantCulture) + " GB";
|
|
if (value >= 1024d * 1024)
|
|
return (value / (1024d * 1024)).ToString("0.##", CultureInfo.InvariantCulture) + " MB";
|
|
if (value >= 1024d)
|
|
return (value / 1024d).ToString("0.##", CultureInfo.InvariantCulture) + " KB";
|
|
return value.ToString("0.##", CultureInfo.InvariantCulture) + " B";
|
|
}
|
|
|
|
private void AdjustRightAnchoredLiveWindow(ref DateTime viewStartUtc, ref DateTime viewEndUtc)
|
|
{
|
|
if (_samples.Count == 0 || viewEndUtc <= viewStartUtc)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DateTime latestSampleUtc = _samples[^1].TimestampUtc;
|
|
if (latestSampleUtc == default || viewEndUtc <= latestSampleUtc)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Only right-anchor in live/near-live windows. Historical windows keep absolute timestamps unchanged.
|
|
if (viewEndUtc < DateTime.UtcNow - TimeSpan.FromSeconds(3))
|
|
{
|
|
return;
|
|
}
|
|
|
|
TimeSpan span = viewEndUtc - viewStartUtc;
|
|
TimeSpan slack = viewEndUtc - latestSampleUtc;
|
|
if (slack <= TimeSpan.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (slack > span)
|
|
{
|
|
slack = span;
|
|
}
|
|
|
|
viewStartUtc -= slack;
|
|
viewEndUtc -= slack;
|
|
}
|
|
|
|
private static double ApplySmoothing(double value, double smoothingAlpha, ref bool hasSmoothed,
|
|
ref double smoothed)
|
|
{
|
|
if (smoothingAlpha <= 0)
|
|
{
|
|
return value;
|
|
}
|
|
|
|
if (!hasSmoothed)
|
|
{
|
|
smoothed = value;
|
|
hasSmoothed = true;
|
|
return smoothed;
|
|
}
|
|
|
|
smoothed += (value - smoothed) * smoothingAlpha;
|
|
return smoothed;
|
|
}
|
|
|
|
private static double Clamp(double v, double min, double max) => v < min ? min : (v > max ? max : v);
|
|
}
|
|
}
|