222 lines
9.2 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|