Files
Encelado/DesktopBot/Controls/PriceLineChart.cs
T
2026-06-09 18:29:41 +02:00

222 lines
9.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using DesktopBot.ViewModels;
namespace DesktopBot.Controls
{
/// <summary>
/// Line chart WPF nativo (nessuna dipendenza esterna).
/// Legge una <see cref="System.Collections.ObjectModel.ObservableCollection{PricePoint}"/>
/// e ridisegna automaticamente ad ogni variazione.
/// </summary>
public class PriceLineChart : FrameworkElement
{
// ── Dependency Properties ────────────────────────────────────────────
public static readonly DependencyProperty PriceDataProperty =
DependencyProperty.Register(
nameof(PriceData),
typeof(IEnumerable<PricePoint>),
typeof(PriceLineChart),
new FrameworkPropertyMetadata(null,
FrameworkPropertyMetadataOptions.AffectsRender,
OnPriceDataChanged));
public IEnumerable<PricePoint> PriceData
{
get => (IEnumerable<PricePoint>)GetValue(PriceDataProperty);
set => SetValue(PriceDataProperty, value);
}
public static readonly DependencyProperty LineColorProperty =
DependencyProperty.Register(
nameof(LineColor), typeof(Color), typeof(PriceLineChart),
new FrameworkPropertyMetadata(Colors.LimeGreen,
FrameworkPropertyMetadataOptions.AffectsRender));
public Color LineColor
{
get => (Color)GetValue(LineColorProperty);
set => SetValue(LineColorProperty, value);
}
// ── Osserva notifiche della collection ──────────────────────────────
private static void OnPriceDataChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var chart = (PriceLineChart)d;
if (e.OldValue is INotifyCollectionChanged oldCol)
oldCol.CollectionChanged -= chart.OnCollectionChanged;
if (e.NewValue is INotifyCollectionChanged newCol)
newCol.CollectionChanged += chart.OnCollectionChanged;
chart.InvalidateVisual();
}
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
=> InvalidateVisual();
// ── Rendering ───────────────────────────────────────────────────────
protected override void OnRender(DrawingContext dc)
{
double w = ActualWidth;
double h = ActualHeight;
if (w < 10 || h < 10) return;
// Sfondo
dc.DrawRectangle(new SolidColorBrush(Color.FromRgb(0x0E, 0x0E, 0x14)),
null, new Rect(0, 0, w, h));
var points = PriceData?.OfType<PricePoint>().ToList();
if (points == null || points.Count < 2)
{
DrawNoData(dc, w, h);
return;
}
const double padLeft = 72;
const double padRight = 76; // spazio per la label del prezzo corrente
const double padTop = 16;
const double padBottom = 32;
double chartW = w - padLeft - padRight;
double chartH = h - padTop - padBottom;
decimal minP = points.Min(p => p.Price);
decimal maxP = points.Max(p => p.Price);
decimal range = maxP - minP;
if (range == 0) range = maxP * 0.01m;
// Griglia orizzontale (5 linee)
var gridPen = new Pen(new SolidColorBrush(Color.FromArgb(40, 255, 255, 255)), 1);
gridPen.Freeze();
for (int i = 0; i <= 4; i++)
{
double y = padTop + chartH * i / 4.0;
dc.DrawLine(gridPen,
new Point(padLeft, y),
new Point(padLeft + chartW, y));
decimal price = maxP - range * (decimal)(i / 4.0);
DrawLabel(dc, price.ToString("N0"), padLeft - 6, y,
Color.FromRgb(0x88, 0x88, 0x99), 9, TextAlignment.Right);
}
// Curva prezzi con fill gradiente
var geometry = new StreamGeometry();
using (var ctx = geometry.Open())
{
for (int i = 0; i < points.Count; i++)
{
double x = padLeft + chartW * i / (double)(points.Count - 1);
double y = padTop + chartH * (double)((maxP - points[i].Price) / range);
if (i == 0) ctx.BeginFigure(new Point(x, y), isFilled: false, isClosed: false);
else ctx.LineTo(new Point(x, y), isStroked: true, isSmoothJoin: true);
}
}
geometry.Freeze();
// Fill sotto la curva
var fillGeometry = new StreamGeometry();
using (var ctx = fillGeometry.Open())
{
double x0 = padLeft;
double xN = padLeft + chartW;
double yBase = padTop + chartH;
ctx.BeginFigure(new Point(x0, yBase), isFilled: true, isClosed: true);
for (int i = 0; i < points.Count; i++)
{
double x = padLeft + chartW * i / (double)(points.Count - 1);
double y = padTop + chartH * (double)((maxP - points[i].Price) / range);
ctx.LineTo(new Point(x, y), isStroked: false, isSmoothJoin: true);
}
ctx.LineTo(new Point(xN, yBase), isStroked: false, isSmoothJoin: false);
}
fillGeometry.Freeze();
var fillBrush = new LinearGradientBrush(
Color.FromArgb(80, LineColor.R, LineColor.G, LineColor.B),
Color.FromArgb(0, LineColor.R, LineColor.G, LineColor.B),
new Point(0, 0), new Point(0, 1));
fillBrush.Freeze();
dc.DrawGeometry(fillBrush, null, fillGeometry);
var linePen = new Pen(new SolidColorBrush(LineColor), 1.8);
linePen.Freeze();
dc.DrawGeometry(null, linePen, geometry);
// Dot sul prezzo corrente (ultimo punto)
var last = points.Last();
double lx = padLeft + chartW;
double ly = padTop + chartH * (double)((maxP - last.Price) / range);
var dotBrush = new SolidColorBrush(LineColor);
dotBrush.Freeze();
dc.DrawEllipse(dotBrush, null, new Point(lx, ly), 4, 4);
// Label prezzo corrente: disegnata a destra del dot, dentro padRight
// Sfondo scuro per leggibilità sul gradiente
var priceText = last.Price.ToString("N2");
var priceTf = new FormattedText(
priceText,
System.Globalization.CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface("Consolas"),
10,
new SolidColorBrush(LineColor),
VisualTreeHelper.GetDpi(new DrawingVisual()).PixelsPerDip);
double labelX = lx + 6;
double labelY = ly - priceTf.Height / 2;
// Sfondo pill
var bgRect = new Rect(labelX - 2, labelY - 1, priceTf.Width + 4, priceTf.Height + 2);
dc.DrawRectangle(new SolidColorBrush(Color.FromRgb(0x0E, 0x0E, 0x14)), null, bgRect);
dc.DrawText(priceTf, new Point(labelX, labelY));
// Etichette asse X (prime e ultime)
if (points.Count >= 2)
{
DrawLabel(dc, points.First().Timestamp.ToString("HH:mm"),
padLeft, padTop + chartH + 6, Color.FromRgb(0x66, 0x66, 0x77), 9, TextAlignment.Left);
DrawLabel(dc, points.Last().Timestamp.ToString("HH:mm"),
padLeft + chartW, padTop + chartH + 6, Color.FromRgb(0x66, 0x66, 0x77), 9, TextAlignment.Right);
}
}
private static void DrawNoData(DrawingContext dc, double w, double h)
{
var tf = new FormattedText(
"In attesa dei dati...",
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface("Segoe UI"),
13,
new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x66)),
VisualTreeHelper.GetDpi(new DrawingVisual()).PixelsPerDip);
dc.DrawText(tf, new Point((w - tf.Width) / 2, (h - tf.Height) / 2));
}
private static void DrawLabel(DrawingContext dc, string text,
double x, double y, Color color, double size, TextAlignment align)
{
var tf = new FormattedText(
text,
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface("Consolas"),
size,
new SolidColorBrush(color),
VisualTreeHelper.GetDpi(new DrawingVisual()).PixelsPerDip);
tf.TextAlignment = align;
dc.DrawText(tf, new Point(x, y - tf.Height / 2));
}
}
}