feat: add SkiaChartRendererV2 (title, colored series, line labels, bottom legend)

This commit is contained in:
2026-05-27 15:50:05 +02:00
parent 494443ede3
commit 0caccc72d5

View File

@@ -0,0 +1,439 @@
using CertReports.Syncfusion.Models;
using SkiaSharp;
namespace CertReports.Syncfusion.Services.Implementations;
/// <summary>
/// Renderer grafico V2 con SkiaSharp.
///
/// Miglioramenti rispetto a v1:
/// - Titolo in cima (NomeCFT + avviso se meno di 30 prezzi)
/// - CTF in rosso (#CC0000, 2.5px), WorstOf in blu (#1565C0, 2px), altri in grigio (1px)
/// - Linee costanti con label direttamente sull'estremità destra
/// - Legenda orizzontale in BASSO (non a destra)
/// - Linea tratteggiata blu per prezzo attuale worst-of (non in legenda)
/// </summary>
public static class SkiaChartRendererV2
{
// ── Colori ────────────────────────────────────────────────────────
private static readonly SKColor CertColor = new(204, 0, 0);
private static readonly SKColor WorstOfColor = new(21, 101, 192);
private static readonly SKColor StrikeColor = new(46, 125, 50);
private static readonly SKColor CapitaleColor = new(204, 0, 0);
private static readonly SKColor CouponColor = new(128, 0, 128);
private static readonly SKColor AutocallColor = new(230, 81, 0);
private static readonly SKColor PrezzoWorstColor = new(21, 101, 192);
private static readonly SKColor TitleColor = new(21, 101, 192);
private static readonly SKColor[] OtherUlColors =
{
new(120, 120, 120),
new(160, 160, 160),
new(90, 90, 90),
new(140, 140, 140),
};
// ── Font helper ───────────────────────────────────────────────────
// NOTE: SKFont is NOT IDisposable in this SkiaSharp version. Do NOT use 'using'.
private static SKFont CreateFont(float size, bool bold = false) =>
new(SKTypeface.FromFamilyName("Arial", bold ? SKFontStyle.Bold : SKFontStyle.Normal), size);
// ═══════════════════════════════════════════════════════════════════
// Entry point
// ═══════════════════════════════════════════════════════════════════
public static byte[] RenderToPng(ChartDataV2 data, int width = 1100, int height = 700)
{
using var surface = SKSurface.Create(new SKImageInfo(width, height));
var canvas = surface.Canvas;
canvas.Clear(SKColors.White);
// Titolo in cima
float titleBottom = DrawTitle(canvas, width, data);
// Margini area plot
float marginLeft = 70;
float marginRight = 210;
float marginTop = titleBottom + 10;
float marginBottom = 95;
var plotArea = new SKRect(marginLeft, marginTop, width - marginRight, height - marginBottom);
// Raggruppa punti per serie
var worstOf = data.Underlyings.FirstOrDefault(u => u.IsWorstOf == 1);
var seriesByUl = data.SeriesPoints
.GroupBy(p => p.IDUnderlyings)
.ToDictionary(g => g.Key, g => g.OrderBy(p => p.Date).ToList());
if (seriesByUl.Count == 0)
return Array.Empty<byte>();
// Calcola range
var (minDate, maxDate, minY, maxY) = CalculateRanges(data, seriesByUl, worstOf);
// Griglia e assi
DrawGrid(canvas, plotArea, minDate, maxDate, minY, maxY);
DrawAxisLabels(canvas, plotArea, minDate, maxDate, minY, maxY);
// ── Linee costanti con label ───────────────────────────────────
var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();
string bcLabel = data.GlobalMeta.BarrieraCouponPerc == data.GlobalMeta.BarrieraCapitalePerc
? $"Barriera {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})"
: $"Barriera Capitale {data.GlobalMeta.BarrieraCapitalePerc:0}% ({data.GlobalMeta.BarrieraCapitale:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel);
constLegend.Add((bcLabel, CapitaleColor, false, 1.5f));
if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc
&& data.GlobalMeta.BarrieraCouponPerc > 0)
{
string bkLabel = $"Barriera Coupon {data.GlobalMeta.BarrieraCouponPerc:0}% ({data.GlobalMeta.BarrieraCoupon:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel);
constLegend.Add((bkLabel, CouponColor, false, 1.5f));
}
string strikeLabel = $"Strike 100% ({data.GlobalMeta.Strike:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
100f, StrikeColor, 1.5f, false, strikeLabel);
constLegend.Add((strikeLabel, StrikeColor, false, 1.5f));
bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0
&& data.GlobalMeta.TriggerAutocallPerc != 100
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;
if (showAutocall)
{
string taLabel = $"Trigger Autocall {data.GlobalMeta.TriggerAutocallPerc:0}% ({data.GlobalMeta.AutocallValue:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel);
constLegend.Add((taLabel, AutocallColor, false, 1.5f));
}
// Prezzo attuale WorstOf — tratteggiato, NON in legenda
if (worstOf != null && worstOf.PriceWorstPerc > 0)
{
string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})";
DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY,
(float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel);
}
// ── Serie ──────────────────────────────────────────────────────
var seriesLegend = new List<(string name, SKColor color, bool dashed, float thickness)>();
int otherColorIdx = 0;
if (seriesByUl.TryGetValue(0, out var ctfPoints) && ctfPoints.Count >= 2)
{
DrawSeriesV2(canvas, plotArea, ctfPoints, minDate, maxDate, minY, maxY, CertColor, 2.5f);
seriesLegend.Add((data.Isin, CertColor, false, 2.5f));
}
foreach (var ul in data.Underlyings.OrderByDescending(u => u.IsWorstOf))
{
if (!seriesByUl.TryGetValue(ul.IDUnderlyings, out var ulPoints) || ulPoints.Count < 2)
continue;
SKColor color;
float thickness;
if (ul.IsWorstOf == 1)
{
color = WorstOfColor;
thickness = 2f;
}
else
{
color = OtherUlColors[otherColorIdx++ % OtherUlColors.Length];
thickness = 1f;
}
DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness);
seriesLegend.Add((ul.Sottostante, color, false, thickness));
}
// Bordo area plot
using var borderPaint = new SKPaint
{
Color = SKColors.Gray, StrokeWidth = 1,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
canvas.DrawRect(plotArea, borderPaint);
// Legenda orizzontale in basso
var allLegend = seriesLegend.Concat(constLegend).ToList();
DrawLegendBottom(canvas, plotArea, allLegend, width);
using var image = surface.Snapshot();
using var pngData = image.Encode(SKEncodedImageFormat.Png, 95);
return pngData.ToArray();
}
// ═══════════════════════════════════════════════════════════════════
// Titolo
// ═══════════════════════════════════════════════════════════════════
private static float DrawTitle(SKCanvas canvas, int width, ChartDataV2 data)
{
float y = 15f;
var boldFont = CreateFont(13f, bold: true);
using var titlePaint = new SKPaint { Color = TitleColor, IsAntialias = true };
canvas.DrawText(data.GlobalMeta.NomeCFT, width / 2f, y + 13, SKTextAlign.Center, boldFont, titlePaint);
y += 22;
if (data.GlobalMeta.NumPrezziCFT < 30)
{
var subFont = CreateFont(10f);
using var subPaint = new SKPaint { Color = new SKColor(204, 0, 0), IsAntialias = true };
const string subtitle = "Il certificato viene mostrato nel grafico solo dopo 30gg dalla sua emissione";
canvas.DrawText(subtitle, width / 2f, y + 11, SKTextAlign.Center, subFont, subPaint);
y += 18;
}
return y + 5;
}
// ═══════════════════════════════════════════════════════════════════
// Calcolo range assi
// ═══════════════════════════════════════════════════════════════════
private static (DateTime minDate, DateTime maxDate, double minY, double maxY) CalculateRanges(
ChartDataV2 data,
Dictionary<int, List<ChartSeriesPoint>> seriesByUl,
ChartUlMetadata? worstOf)
{
DateTime minDate = DateTime.MaxValue, maxDate = DateTime.MinValue;
double minY = double.MaxValue, maxY = double.MinValue;
foreach (var pts in seriesByUl.Values)
{
foreach (var pt in pts)
{
if (pt.Date < minDate) minDate = pt.Date;
if (pt.Date > maxDate) maxDate = pt.Date;
double v = (double)pt.Performance;
if (v < minY) minY = v;
if (v > maxY) maxY = v;
}
}
var constants = new List<double> { 100.0, (double)data.GlobalMeta.BarrieraCapitalePerc };
if (data.GlobalMeta.BarrieraCouponPerc > 0)
constants.Add((double)data.GlobalMeta.BarrieraCouponPerc);
bool hasAutocall = data.GlobalMeta.TriggerAutocallPerc != 0
&& data.GlobalMeta.TriggerAutocallPerc != 100
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc
&& data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc;
if (hasAutocall)
constants.Add((double)data.GlobalMeta.TriggerAutocallPerc);
if (worstOf != null && worstOf.PriceWorstPerc > 0)
constants.Add((double)worstOf.PriceWorstPerc);
foreach (var c in constants)
{
if (c < minY) minY = c;
if (c > maxY) maxY = c;
}
double range = maxY - minY;
if (range == 0) range = 10;
double margin = range * 0.1;
minY -= margin;
maxY += margin;
return (minDate, maxDate, minY, maxY);
}
// ═══════════════════════════════════════════════════════════════════
// Griglia
// ═══════════════════════════════════════════════════════════════════
private static void DrawGrid(SKCanvas canvas, SKRect area,
DateTime minDate, DateTime maxDate, double minY, double maxY)
{
using var gridPaint = new SKPaint
{
Color = new SKColor(230, 230, 230), StrokeWidth = 0.5f,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
int ySteps = 8;
for (int i = 0; i <= ySteps; i++)
{
float y = area.Top + (area.Height / ySteps) * i;
canvas.DrawLine(area.Left, y, area.Right, y, gridPaint);
}
var totalDays = (maxDate - minDate).TotalDays;
int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90;
var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1);
while (d <= maxDate)
{
float x = DateToX(d, area, minDate, maxDate);
if (x >= area.Left && x <= area.Right)
canvas.DrawLine(x, area.Top, x, area.Bottom, gridPaint);
d = d.AddDays(step);
}
}
// ═══════════════════════════════════════════════════════════════════
// Labels assi
// ═══════════════════════════════════════════════════════════════════
private static void DrawAxisLabels(SKCanvas canvas, SKRect area,
DateTime minDate, DateTime maxDate, double minY, double maxY)
{
var font = CreateFont(11);
using var paint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
int ySteps = 8;
for (int i = 0; i <= ySteps; i++)
{
double val = maxY - ((maxY - minY) / ySteps) * i;
float y = area.Top + (area.Height / ySteps) * i;
canvas.DrawText($"{val:F0} %", area.Left - 55, y + 4, SKTextAlign.Left, font, paint);
}
var totalDays = (maxDate - minDate).TotalDays;
int step = totalDays > 1000 ? 365 : totalDays > 500 ? 180 : 90;
var d = new DateTime(minDate.Year, minDate.Month > 6 ? 7 : 1, 1);
while (d <= maxDate)
{
float x = DateToX(d, area, minDate, maxDate);
if (x >= area.Left && x <= area.Right)
{
string text = totalDays > 500 ? d.ToString("yyyy") : d.ToString("MMM yyyy");
canvas.DrawText(text, x, area.Bottom + 20, SKTextAlign.Center, font, paint);
}
d = d.AddDays(step);
}
}
// ═══════════════════════════════════════════════════════════════════
// Serie
// ═══════════════════════════════════════════════════════════════════
private static void DrawSeriesV2(SKCanvas canvas, SKRect area,
List<ChartSeriesPoint> points,
DateTime minDate, DateTime maxDate, double minY, double maxY,
SKColor color, float thickness)
{
using var paint = new SKPaint
{
Color = color, StrokeWidth = thickness,
Style = SKPaintStyle.Stroke, IsAntialias = true,
StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Round,
};
using var path = new SKPath();
bool first = true;
foreach (var pt in points)
{
float x = DateToX(pt.Date, area, minDate, maxDate);
float y = ValueToY((double)pt.Performance, area, minY, maxY);
y = Math.Max(area.Top, Math.Min(area.Bottom, y));
if (first) { path.MoveTo(x, y); first = false; }
else path.LineTo(x, y);
}
canvas.Save();
canvas.ClipRect(area);
canvas.DrawPath(path, paint);
canvas.Restore();
}
// ═══════════════════════════════════════════════════════════════════
// Linea orizzontale costante con label a destra
// ═══════════════════════════════════════════════════════════════════
private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area,
double minY, double maxY,
float value, SKColor color, float thickness, bool dashed, string label)
{
float y = ValueToY(value, area, minY, maxY);
if (y < area.Top || y > area.Bottom) return;
using var linePaint = new SKPaint
{
Color = color, StrokeWidth = thickness,
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
if (dashed)
linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0);
canvas.DrawLine(area.Left, y, area.Right, y, linePaint);
var font = CreateFont(9.5f);
using var textPaint = new SKPaint { Color = color, IsAntialias = true };
canvas.DrawText(label, area.Right + 5, y + 4, SKTextAlign.Left, font, textPaint);
}
// ═══════════════════════════════════════════════════════════════════
// Legenda orizzontale in basso
// ═══════════════════════════════════════════════════════════════════
private static void DrawLegendBottom(SKCanvas canvas, SKRect plotArea,
List<(string name, SKColor color, bool dashed, float thickness)> items, int totalWidth)
{
if (items.Count == 0) return;
float legendY = plotArea.Bottom + 38;
float x = plotArea.Left;
const float lineW = 22;
const float gap = 6;
const float itemGap = 14;
const float rowHeight = 19;
var font = CreateFont(10f);
foreach (var (name, color, dashed, thickness) in items)
{
float textW = font.MeasureText(name);
float itemW = lineW + gap + textW + itemGap;
if (x + itemW > totalWidth - plotArea.Left && x > plotArea.Left)
{
x = plotArea.Left;
legendY += rowHeight;
}
float midY = legendY + rowHeight / 2f - 2;
using var linePaint = new SKPaint
{
Color = color, StrokeWidth = Math.Min(thickness, 2f),
Style = SKPaintStyle.Stroke, IsAntialias = true,
};
if (dashed)
linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 6f, 3f }, 0);
canvas.DrawLine(x, midY, x + lineW, midY, linePaint);
using var textPaint = new SKPaint { Color = SKColors.DimGray, IsAntialias = true };
canvas.DrawText(name, x + lineW + gap, midY + 4, SKTextAlign.Left, font, textPaint);
x += itemW;
}
}
// ═══════════════════════════════════════════════════════════════════
// Conversioni coordinate
// ═══════════════════════════════════════════════════════════════════
private static float DateToX(DateTime date, SKRect area, DateTime minDate, DateTime maxDate)
{
double totalDays = (maxDate - minDate).TotalDays;
if (totalDays == 0) return area.Left;
double ratio = (date - minDate).TotalDays / totalDays;
return area.Left + (float)(ratio * area.Width);
}
private static float ValueToY(double value, SKRect area, double minY, double maxY)
{
double range = maxY - minY;
if (range == 0) return area.MidY;
double ratio = (value - minY) / range;
return area.Bottom - (float)(ratio * area.Height);
}
}