From 0caccc72d52beb0cdd6c140e145ba37f4545794b Mon Sep 17 00:00:00 2001 From: SmartRootsSrl Date: Wed, 27 May 2026 15:50:05 +0200 Subject: [PATCH] feat: add SkiaChartRendererV2 (title, colored series, line labels, bottom legend) --- .../Implementations/SkiaChartRendererV2.cs | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs diff --git a/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs b/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs new file mode 100644 index 0000000..5b34ed8 --- /dev/null +++ b/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs @@ -0,0 +1,439 @@ +using CertReports.Syncfusion.Models; +using SkiaSharp; + +namespace CertReports.Syncfusion.Services.Implementations; + +/// +/// 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) +/// +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(); + + // 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> 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 { 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 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); + } +}