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);
+ }
+}