# Chart V2 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Aggiungere l'endpoint `GET /api/chart/v2/{isin}` che genera un grafico certificati migliorato con titolo, colori distinti CTF/WorstOf, label sulle linee costanti e legenda orizzontale in basso. **Architecture:** Due nuove SP (`cedlab_Chart_UL1` per metadata, `cedlab_Chart_AllSeriesV2` per tutte le serie in un unico round-trip), tre nuovi file C# (modelli, servizio, renderer), e un secondo action method in `ChartController`. Il vecchio v1 rimane invariato. **Tech Stack:** ASP.NET Core 8, SkiaSharp, Microsoft.Data.SqlClient, SQL Server (`FirstSolutionDB`) --- ## Prerequisito DB — SP da creare PRIMA di eseguire i task C# Le due stored procedure devono esistere in `FirstSolutionDB`. Schemi: ### `cedlab_Chart_UL1 @isin NVARCHAR(12)` Estende `FSWeb_Chart_UL` con campi aggiuntivi. Deve restituire: | Colonna | Tipo | Descrizione | |---------|------|-------------| | IDCertificates | int | ID certificato | | IDUnderlyings | int | ID sottostante | | StartDate | date | Data inizio certificato | | Strike | decimal | Valore strike assoluto | | BarrieraCouponPerc | decimal | Barriera coupon in % (es. 60) | | BarrieraCoupon | decimal | Barriera coupon assoluta | | BarrieraCapitalePerc | decimal | Barriera capitale in % (es. 60) | | BarrieraCapitale | decimal | Barriera capitale assoluta | | Sottostante | nvarchar | Nome sottostante | | IsWorstOf | int | 1 = è il worst-of, 0 = altri | | PriceWorst | decimal | Prezzo attuale del worst-of (0 per altri) | | PriceWorstPerc | decimal | Prezzo worst-of in % su strike (0 per altri) | | NumPrezziCFT | int | Numero prezzi EOD disponibili | | NomeCFT | nvarchar | Descrizione + ' - ' + ISIN | | TriggerAutocallPerc | decimal | Trigger autocall in % (0 se assente) | | AutocallValue | decimal | Trigger autocall assoluto | Ordinato: `IsWorstOf DESC` (worst-of nella prima riga). ### `cedlab_Chart_AllSeriesV2 @isin NVARCHAR(12)` Restituisce tutte le serie CTF + UL in un unico resultset: | Colonna | Tipo | Descrizione | |---------|------|-------------| | IDUnderlyings | int | 0 = CTF, altrimenti = UnderlyingsID | | Px_date | date | Data EOD | | Performance | decimal | % su strike (CTF: PX_LAST_EOD/Nominal*100; UL: Px_close(adj)/Strike*100) | Logica: - TOP 350 per-serie via `ROW_NUMBER() OVER (PARTITION BY IDUnderlyings ORDER BY Px_date DESC)` - UL: solo date presenti nella serie CTF (INNER JOIN su Px_date), px_date >= StartDate - UL AdjustedPrices: `CASE WHEN AdjustedPrices=1 THEN Px_closeadj ELSE Px_close END` - ORDER BY IDUnderlyings ASC, Px_date ASC --- ## File da creare / modificare | Operazione | File | |------------|------| | **Crea** | `CertReports.Syncfusion/Models/ChartModelsV2.cs` | | **Crea** | `CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs` | | **Crea** | `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs` | | **Modifica** | `CertReports.Syncfusion/Controllers/ChartController.cs` | | **Modifica** | `CertReports.Syncfusion/Program.cs` | --- ## Task 1: ChartModelsV2 **Files:** - Create: `CertReports.Syncfusion/Models/ChartModelsV2.cs` - [ ] **Step 1: Crea il file** ```csharp namespace CertReports.Syncfusion.Models; /// /// Metadata di un sottostante (da SP cedlab_Chart_UL1). /// Prima riga = worst-of (IsWorstOf = 1). /// public class ChartUlMetadata { public int IDCertificates { get; set; } public int IDUnderlyings { get; set; } public DateTime StartDate { get; set; } public decimal Strike { get; set; } public decimal BarrieraCouponPerc { get; set; } public decimal BarrieraCoupon { get; set; } public decimal BarrieraCapitalePerc { get; set; } public decimal BarrieraCapitale { get; set; } public string Sottostante { get; set; } = string.Empty; public int IsWorstOf { get; set; } public decimal PriceWorst { get; set; } public decimal PriceWorstPerc { get; set; } public int NumPrezziCFT { get; set; } public string NomeCFT { get; set; } = string.Empty; public decimal TriggerAutocallPerc { get; set; } public decimal AutocallValue { get; set; } } /// /// Singolo punto di una serie (da SP cedlab_Chart_AllSeriesV2). /// IDUnderlyings = 0 → serie del certificato. /// public class ChartSeriesPoint { public int IDUnderlyings { get; set; } public DateTime Date { get; set; } public decimal Performance { get; set; } } /// /// Dati completi per il grafico V2. /// public class ChartDataV2 { public string Isin { get; set; } = string.Empty; /// /// Metadata globale: prima riga di cedlab_Chart_UL1 (il worst-of). /// Contiene NomeCFT, NumPrezziCFT, barriere, trigger — uguali per tutte le righe. /// public ChartUlMetadata GlobalMeta { get; set; } = new(); /// Tutti i sottostanti (per IsWorstOf, PriceWorst, nomi legenda). public List Underlyings { get; set; } = new(); /// Tutti i punti di tutte le serie (CTF + UL), ordinati per data. public List SeriesPoints { get; set; } = new(); } ``` - [ ] **Step 2: Verifica compilazione** ```powershell dotnet build CertReports.Syncfusion ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 3: Commit** ```bash git add CertReports.Syncfusion/Models/ChartModelsV2.cs git commit -m "feat: add ChartModelsV2 (ChartUlMetadata, ChartSeriesPoint, ChartDataV2)" ``` --- ## Task 2: ChartDataServiceV2 **Files:** - Create: `CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs` ⚠️ Prerequisito: le SP `cedlab_Chart_UL1` e `cedlab_Chart_AllSeriesV2` devono già esistere nel DB. - [ ] **Step 1: Crea il file** ```csharp using CertReports.Syncfusion.Models; using Microsoft.Data.SqlClient; using System.Data; namespace CertReports.Syncfusion.Services.Implementations; /// /// Recupera i dati per il grafico V2 con solo 2 round-trip al DB. /// /// SP utilizzate: /// - cedlab_Chart_UL1: Metadata sottostanti (1 query, N sottostanti) /// - cedlab_Chart_AllSeriesV2: Tutte le serie CTF + UL in una query (TOP 350 per-serie) /// public interface IChartDataServiceV2 { Task GetChartDataV2Async(string isin); } public class ChartDataServiceV2 : IChartDataServiceV2 { private readonly string _connectionString; private readonly ILogger _logger; public ChartDataServiceV2(IConfiguration config, ILogger logger) { _connectionString = config.GetConnectionString("CertDb") ?? throw new InvalidOperationException("ConnectionString 'CertDb' non configurata."); _logger = logger; } public async Task GetChartDataV2Async(string isin) { await using var conn = new SqlConnection(_connectionString); await conn.OpenAsync(); // ── 1. Metadata sottostanti (cedlab_Chart_UL1) ───────────────── var underlyings = new List(); await using (var cmd = new SqlCommand("cedlab_Chart_UL1", conn) { CommandType = CommandType.StoredProcedure }) { cmd.Parameters.AddWithValue("@isin", isin); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) { underlyings.Add(new ChartUlMetadata { IDCertificates = r.GetInt32(r.GetOrdinal("IDCertificates")), IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")), StartDate = r.GetDateTime(r.GetOrdinal("StartDate")), Strike = r.GetDecimal(r.GetOrdinal("Strike")), BarrieraCouponPerc = r.GetDecimal(r.GetOrdinal("BarrieraCouponPerc")), BarrieraCoupon = r.GetDecimal(r.GetOrdinal("BarrieraCoupon")), BarrieraCapitalePerc = r.GetDecimal(r.GetOrdinal("BarrieraCapitalePerc")), BarrieraCapitale = r.GetDecimal(r.GetOrdinal("BarrieraCapitale")), Sottostante = r.GetString(r.GetOrdinal("Sottostante")), IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")), PriceWorst = r.GetDecimal(r.GetOrdinal("PriceWorst")), PriceWorstPerc = r.GetDecimal(r.GetOrdinal("PriceWorstPerc")), NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")), NomeCFT = r.GetString(r.GetOrdinal("NomeCFT")), TriggerAutocallPerc = r.GetDecimal(r.GetOrdinal("TriggerAutocallPerc")), AutocallValue = r.GetDecimal(r.GetOrdinal("AutocallValue")), }); } } if (underlyings.Count == 0) { _logger.LogWarning( "Nessun sottostante trovato per il grafico V2 di {Isin} (meno di 30 prezzi EOD?)", isin); return null; } var result = new ChartDataV2 { Isin = isin, GlobalMeta = underlyings[0], // worst-of è il primo (SP ordina IsWorstOf DESC) Underlyings = underlyings, }; // ── 2. Tutte le serie (cedlab_Chart_AllSeriesV2) ──────────────── await using (var cmd = new SqlCommand("cedlab_Chart_AllSeriesV2", conn) { CommandType = CommandType.StoredProcedure }) { cmd.Parameters.AddWithValue("@isin", isin); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) { result.SeriesPoints.Add(new ChartSeriesPoint { IDUnderlyings = r.GetInt32(r.GetOrdinal("IDUnderlyings")), Date = r.GetDateTime(r.GetOrdinal("Px_date")), Performance = r.GetDecimal(r.GetOrdinal("Performance")), }); } } _logger.LogInformation( "Dati grafico V2 caricati per {Isin}: {UlCount} sottostanti, {Points} punti totali", isin, underlyings.Count, result.SeriesPoints.Count); return result; } } ``` - [ ] **Step 2: Verifica compilazione** ```powershell dotnet build CertReports.Syncfusion ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 3: Commit** ```bash git add CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs git commit -m "feat: add ChartDataServiceV2 with IChartDataServiceV2 (2-SP approach)" ``` --- ## Task 3: SkiaChartRendererV2 **Files:** - Create: `CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs` - [ ] **Step 1: Crea il file** (`~380 righe`) ```csharp using CertReports.Syncfusion.Models; using SkiaSharp; namespace CertReports.Syncfusion.Services.Implementations; /// /// Renderer grafico V2 per certificati con SkiaSharp. /// /// Miglioramenti rispetto a v1: /// - Titolo in cima (NomeCFT + avviso se < 30 prezzi) /// - CTF in rosso (#CC0000), WorstOf in blu (#1565C0), altri in grigio /// - 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 V2 ────────────────────────────────────────────────────── private static readonly SKColor CertColor = new(204, 0, 0); // #CC0000 rosso CTF private static readonly SKColor WorstOfColor = new(21, 101, 192); // #1565C0 blu WorstOf private static readonly SKColor StrikeColor = new(46, 125, 50); // #2E7D32 verde private static readonly SKColor CapitaleColor = new(204, 0, 0); // rosso (= CTF) private static readonly SKColor CouponColor = new(128, 0, 128); // viola private static readonly SKColor AutocallColor = new(230, 81, 0); // arancione private static readonly SKColor PrezzoWorstColor = new(21, 101, 192); // blu tratteggiato private static readonly SKColor TitleColor = new(21, 101, 192); // blu titolo private static readonly SKColor[] OtherUlColors = { new(120, 120, 120), new(160, 160, 160), new(90, 90, 90), new(140, 140, 140), }; // ── Font ─────────────────────────────────────────────────────────── private static SKFont CreateFont(float size, bool bold = false) => new(SKTypeface.FromFamilyName("Arial", bold ? SKFontStyle.Bold : SKFontStyle.Normal), size); // ═══════════════════════════════════════════════════════════════════ // Entry point // ═══════════════════════════════════════════════════════════════════ /// Genera il grafico come PNG. 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 ───────────────────────────────────────────────────── float titleBottom = DrawTitle(canvas, width, data); // ── Margini area plot ────────────────────────────────────────── float marginLeft = 70; float marginRight = 210; // spazio per label linee costanti float marginTop = titleBottom + 10; float marginBottom = 95; // asse X + legenda orizzontale 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 assi ───────────────────────────────────────── 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)>(); // Barriera Capitale (label unificata se stessa % di Coupon) 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)); // Barriera Coupon (solo se diversa) 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)); } // Strike 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)); // Trigger Autocall (solo se applicabile) 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; // CTF (IDUnderlyings = 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)); } // Sottostanti (WorstOf prima, poi altri) 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); // ── Export PNG ───────────────────────────────────────────────── 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; // Riga 1: NomeCFT in grassetto blu using 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; // Riga 2 (opzionale): avviso se meno di 30 prezzi CTF if (data.GlobalMeta.NumPrezziCFT < 30) { using 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; // bottom della zona titolo } // ═══════════════════════════════════════════════════════════════════ // 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; } } // Includi linee costanti nel range var constants = new List { 100.0, (double)data.GlobalMeta.BarrieraCapitalePerc, }; if (data.GlobalMeta.BarrieraCouponPerc > 0) constants.Add((double)data.GlobalMeta.BarrieraCouponPerc); if (showAutocallValue(data)) 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; } // Margine 10% double range = maxY - minY; if (range == 0) range = 10; double margin = range * 0.1; minY -= margin; maxY += margin; return (minDate, maxDate, minY, maxY); } private static bool showAutocallValue(ChartDataV2 data) => data.GlobalMeta.TriggerAutocallPerc != 0 && data.GlobalMeta.TriggerAutocallPerc != 100 && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCapitalePerc && data.GlobalMeta.TriggerAutocallPerc != data.GlobalMeta.BarrieraCouponPerc; // ═══════════════════════════════════════════════════════════════════ // 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) { using 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); } } // ═══════════════════════════════════════════════════════════════════ // Disegno 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); // Label a destra della linea using 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 (flow layout, max 2 righe) // ═══════════════════════════════════════════════════════════════════ 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; // sotto le label asse X float x = plotArea.Left; const float lineW = 22; const float gap = 6; const float itemGap = 14; const float rowHeight = 19; using var font = CreateFont(10f); foreach (var (name, color, dashed, thickness) in items) { float textW = font.MeasureText(name); float itemW = lineW + gap + textW + itemGap; // Vai a capo se non c'è spazio if (x + itemW > totalWidth - plotArea.Left + plotArea.Left && x > plotArea.Left) { x = plotArea.Left; legendY += rowHeight; } float midY = legendY + rowHeight / 2f - 2; // Linea campione 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); // Testo 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 (identiche a v1) // ═══════════════════════════════════════════════════════════════════ 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); } } ``` - [ ] **Step 2: Verifica compilazione** ```powershell dotnet build CertReports.Syncfusion ``` Expected: `Build succeeded. 0 Error(s)` > 💡 Se il build fallisce su `showAutocallValue` (metodo privato statico con nome in minuscolo): rinomina in `ShowAutocallValue` e aggiorna i due riferimenti in `CalculateRanges` e nel body di `RenderToPng`. - [ ] **Step 3: Commit** ```bash git add CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs git commit -m "feat: add SkiaChartRendererV2 (title, colored series, line labels, bottom legend)" ``` --- ## Task 4: Controller V2 + DI **Files:** - Modify: `CertReports.Syncfusion/Controllers/ChartController.cs` - Modify: `CertReports.Syncfusion/Program.cs` - [ ] **Step 1: Aggiorna `ChartController.cs` — aggiungi injection e action V2** Aggiungi `IChartDataServiceV2 _chartDataServiceV2` al controller. Modifica il costruttore e aggiungi il nuovo action method: Nella sezione campi (dopo `private readonly IChartDataService _chartDataService;`): ```csharp private readonly IChartDataServiceV2 _chartDataServiceV2; ``` Sostituisci il costruttore esistente: ```csharp // PRIMA: public ChartController(IChartDataService chartDataService, ILogger logger) { _chartDataService = chartDataService; _logger = logger; } // DOPO: public ChartController( IChartDataService chartDataService, IChartDataServiceV2 chartDataServiceV2, ILogger logger) { _chartDataService = chartDataService; _chartDataServiceV2 = chartDataServiceV2; _logger = logger; } ``` Aggiungi il nuovo action method subito dopo il `}` di `GenerateChart` e prima di `WrapPngInPdf`: ```csharp /// /// Endpoint V2: grafico migliorato con titolo, colori distinti, label sulle linee e legenda in basso. /// Richiede SP cedlab_Chart_UL1 e cedlab_Chart_AllSeriesV2 nel DB. /// [HttpGet("v2/{isin}")] public async Task GenerateChartV2( string isin, [FromQuery] int width = 1100, [FromQuery] int height = 700, [FromQuery] string format = "png") { if (string.IsNullOrWhiteSpace(isin)) return BadRequest("ISIN non valido."); width = Math.Clamp(width, 400, 2000); height = Math.Clamp(height, 300, 1500); try { var chartData = await _chartDataServiceV2.GetChartDataV2Async(isin); if (chartData == null || chartData.SeriesPoints.Count == 0) { return NotFound(new { status = "KO", message = $"Nessun dato per il grafico V2 di {isin} (meno di 30 prezzi EOD?).", }); } byte[] pngBytes = SkiaChartRendererV2.RenderToPng(chartData, width, height); if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) { byte[] pdfBytes = WrapPngInPdf(pngBytes); Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.pdf"); return File(pdfBytes, "application/pdf"); } Response.Headers.Append("Content-Disposition", $"inline; filename=chart_v2_{isin}.png"); return File(pngBytes, "image/png"); } catch (Exception ex) { _logger.LogError(ex, "Errore generazione chart V2 per ISIN {Isin}", isin); return StatusCode(500, new { status = "KO", message = "Errore nella generazione del grafico V2." }); } } ``` - [ ] **Step 2: Registra il servizio in `Program.cs`** Aggiungi questa riga subito dopo `builder.Services.AddScoped();`: ```csharp builder.Services.AddScoped(); ``` - [ ] **Step 3: Verifica compilazione** ```powershell dotnet build CertReports.Syncfusion ``` Expected: `Build succeeded. 0 Error(s)` - [ ] **Step 4: Test manuale — avvia l'API** ```powershell dotnet run --project CertReports.Syncfusion ``` - [ ] **Step 5: Chiama il nuovo endpoint** ``` GET https://localhost:{porta}/api/chart/v2/{ISIN} GET https://localhost:{porta}/api/chart/v2/{ISIN}?format=pdf GET https://localhost:{porta}/api/chart/v2/{ISIN}?width=1400&height=800 ``` Verifica nel PNG/PDF che: - [ ] Titolo NomeCFT visibile in blu in cima - [ ] Linea certificato in **rosso** (non nera come v1) - [ ] Linea worst-of in **blu** più spessa degli altri UL - [ ] Linee barriere con **label testuale** a destra (non solo in legenda) - [ ] Legenda **in basso** (non a destra) - [ ] Linea tratteggiata blu per prezzo attuale worst-of (se PriceWorstPerc > 0) - [ ] Se `NumPrezziCFT < 30`: sub-titolo rosso con avviso - [ ] **Step 6: Commit** ```bash git add CertReports.Syncfusion/Controllers/ChartController.cs git add CertReports.Syncfusion/Program.cs git commit -m "feat: add /api/chart/v2/{isin} endpoint and register IChartDataServiceV2" ``` --- ## Self-Review ### Spec coverage - ✅ 2 SP (cedlab_Chart_UL1 + cedlab_Chart_AllSeriesV2) → Task 2 - ✅ Modelli ChartUlMetadata, ChartSeriesPoint, ChartDataV2 → Task 1 - ✅ Titolo NomeCFT + sub-titolo se < 30 prezzi → Task 3 `DrawTitle` - ✅ CTF rosso 2.5px, WorstOf blu 2px, altri grigi 1px → Task 3 serie loop - ✅ Label su linee costanti (Strike, Barriera Capitale, Coupon, Autocall) → Task 3 `DrawHorizontalLineWithLabel` - ✅ Label unificata se BarrieraCouponPerc == BarrieraCapitalePerc → Task 3 - ✅ TriggerAutocall: solo se != 0 && != 100 && != Capitale && != Coupon → Task 3 - ✅ PrezzoWorst tratteggiato, NON in legenda → Task 3 - ✅ Legenda orizzontale in basso → Task 3 `DrawLegendBottom` - ✅ Endpoint `/api/chart/v2/{isin}` con ?width ?height ?format → Task 4 - ✅ Riuso di WrapPngInPdf dal v1 → Task 4 - ✅ Registrazione DI → Task 4 ### Note implementative - Il `DrawLegendBottom` usa un layout flow. Se `x + itemW > totalWidth - plotArea.Left + plotArea.Left` la condizione si semplifica a `x + itemW > totalWidth`. Verificare a runtime se la legenda deborda; se sì, diminuire il font a 9.5f o ridurre `itemGap`. - `marginBottom = 95` dà spazio per asse X (20px) + 2 righe di legenda (38px). Se gli item leggenda sono molti, aumentare a 110.