From c655cdd31eae62e566dbde990f16214faf0e3f00 Mon Sep 17 00:00:00 2001 From: SmartRootsSrl Date: Fri, 29 May 2026 10:27:44 +0200 Subject: [PATCH] fix: null-safe string reader, chart V2 label collision avoidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChartDataServiceV2: aggiunto helper ToStr() per GetString null-safe su colonne nullable (Sottostante, NomeCFT); pattern analogo a ToDecimal - SkiaChartRendererV2: collision avoidance label margine destro — tutti i label (barriere, strike, autocall, end-label serie) raccolti in lista, ordinati per Y e distribuiti con spacing minimo 13px (push-down + clamp-up) prima del disegno - CLAUDE.md: documentati i due fix e la root cause cedlab_Chart_UL1 divide-by-zero su Strike=0 (fix DB: NULLIF(cu.Strike,0)) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- CLAUDE.md | 4 ++ .../Implementations/ChartDataServiceV2.cs | 10 ++- .../Implementations/SkiaChartRendererV2.cs | 64 ++++++++++++++----- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 363836b..52d9f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,8 @@ HTTP (ISIN) → ReportController → ReportOrchestrator | Namespace conflict `CertReports.Syncfusion.Pdf` | Aggiungere `using Syncfusion.Pdf;` esplicito nel file | | SkiaSharp `SKFont` NON è IDisposable | Non usare `using` su `SKFont` — solo `SKPaint`, `SKPath`, `SKSurface`, `SKImage`, `SKData` sono IDisposable | | `SqlDataReader.GetDecimal` su colonna `float` SQL | Usare `Convert.ToDecimal(r.GetValue(ord))` — le SP cedlab_ possono restituire `float` (es. `PriceWorst` da subquery su `Prices.Px_close`) | +| `SqlDataReader.GetString` su colonna nullable | Usare `ToStr(r, column)` (helper in `ChartDataServiceV2`) che fa `r.IsDBNull` prima — `GetString` su `DBNull` lancia `SqlNullValueException` | +| `cedlab_Chart_UL1`: Divide by zero su `Strike = 0` | La SP divide per `cu.Strike` senza guard nel calcolo `PriceWorstPerc`. Fix applicato nel DB: `/ NULLIF(cu.Strike, 0)`. Colpisce certificati con Strike non impostato in `CertificatesUnderlyings` | ## Grafico V2 (`/api/chart/v2/{isin}`) @@ -126,6 +128,8 @@ Percorsi configurati in `appsettings.json` → `ChartSettings:SavePath` e `Chart **Note renderer**: CTF non mostrato se `NumPrezziCFT < 30` (mostra solo avviso testo); asse X usa intervalli mensili adattivi (12m/6m/3m/1m in base al range). +**Label destra — collision avoidance**: i label sul margine destro (barriere, strike, autocall, end-label serie) vengono raccolti in una lista `rightLabels`, ordinati per Y, e disegnati da `DrawRightLabels` con spacing minimo 13px. Due passate: discendente (push-down) + risalente (clamp al bordo area). Questo evita la sovrapposizione quando una serie si accavalla con una linea costante. + ## Database Tutte le stored procedure sono su `FirstSolutionDB`. I dati tornano **già formattati come stringhe** dalle SP (es. `FORMAT(value,'P2','it-IT')`): i modelli C# usano `string` per questi campi. Solo `NominalValue`, `PrezzoEmissione`, `CpnPagati`, `CpnDaPagare`, `CpnInMemoria` sono `decimal?` perché servono come valori numerici nel rendering. diff --git a/CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs b/CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs index 7e04643..c01f38f 100644 --- a/CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs +++ b/CertReports.Syncfusion/Services/Implementations/ChartDataServiceV2.cs @@ -60,12 +60,12 @@ public class ChartDataServiceV2 : IChartDataServiceV2 BarrieraCoupon = ToDecimal(r, "BarrieraCoupon"), BarrieraCapitalePerc = ToDecimal(r, "BarrieraCapitalePerc"), BarrieraCapitale = ToDecimal(r, "BarrieraCapitale"), - Sottostante = r.GetString(r.GetOrdinal("Sottostante")), + Sottostante = ToStr(r, "Sottostante"), IsWorstOf = r.GetInt32(r.GetOrdinal("IsWorstOf")), PriceWorst = ToDecimal(r, "PriceWorst"), PriceWorstPerc = ToDecimal(r, "PriceWorstPerc"), NumPrezziCFT = r.GetInt32(r.GetOrdinal("NumPrezziCFT")), - NomeCFT = r.GetString(r.GetOrdinal("NomeCFT")), + NomeCFT = ToStr(r, "NomeCFT"), TriggerAutocallPerc = ToDecimal(r, "TriggerAutocallPerc"), AutocallValue = ToDecimal(r, "AutocallValue"), }); @@ -134,4 +134,10 @@ public class ChartDataServiceV2 : IChartDataServiceV2 if (r.IsDBNull(ord)) return 0m; return Convert.ToDecimal(r.GetValue(ord)); } + + private static string ToStr(SqlDataReader r, string column) + { + int ord = r.GetOrdinal(column); + return r.IsDBNull(ord) ? string.Empty : r.GetString(ord); + } } diff --git a/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs b/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs index 21d5e3f..447bbf3 100644 --- a/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs +++ b/CertReports.Syncfusion/Services/Implementations/SkiaChartRendererV2.cs @@ -79,12 +79,13 @@ public static class SkiaChartRendererV2 // ── Linee costanti con label ─────────────────────────────────── var constLegend = new List<(string name, SKColor color, bool dashed, float thickness)>(); + var rightLabels = new List<(float Y, string Text, SKColor Color)>(); 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); + (float)data.GlobalMeta.BarrieraCapitalePerc, CapitaleColor, 1.5f, false, bcLabel, rightLabels); constLegend.Add((bcLabel, CapitaleColor, false, 1.5f)); if (data.GlobalMeta.BarrieraCouponPerc != data.GlobalMeta.BarrieraCapitalePerc @@ -92,13 +93,13 @@ public static class SkiaChartRendererV2 { 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); + (float)data.GlobalMeta.BarrieraCouponPerc, CouponColor, 1.5f, false, bkLabel, rightLabels); 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); + 100f, StrikeColor, 1.5f, false, strikeLabel, rightLabels); constLegend.Add((strikeLabel, StrikeColor, false, 1.5f)); bool showAutocall = data.GlobalMeta.TriggerAutocallPerc != 0 @@ -109,7 +110,7 @@ public static class SkiaChartRendererV2 { 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); + (float)data.GlobalMeta.TriggerAutocallPerc, AutocallColor, 1.5f, false, taLabel, rightLabels); constLegend.Add((taLabel, AutocallColor, false, 1.5f)); } @@ -118,7 +119,7 @@ public static class SkiaChartRendererV2 { string pwLabel = $"{worstOf.Sottostante} ({worstOf.PriceWorst:0.00})"; DrawHorizontalLineWithLabel(canvas, plotArea, minY, maxY, - (float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel); + (float)worstOf.PriceWorstPerc, PrezzoWorstColor, 1f, true, pwLabel, rightLabels); } // ── Serie ────────────────────────────────────────────────────── @@ -159,10 +160,12 @@ public static class SkiaChartRendererV2 } DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness); - DrawSeriesEndLabel(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, endLabel); + DrawSeriesEndLabel(plotArea, ulPoints, minDate, maxDate, minY, maxY, color, endLabel, rightLabels); seriesLegend.Add((seriesLabel, color, false, thickness)); } + DrawRightLabels(canvas, plotArea, rightLabels); + // Bordo area plot using var borderPaint = new SKPaint { @@ -372,19 +375,17 @@ public static class SkiaChartRendererV2 // Label al termine di una serie (estremo destro) // ═══════════════════════════════════════════════════════════════════ - private static void DrawSeriesEndLabel(SKCanvas canvas, SKRect area, + private static void DrawSeriesEndLabel(SKRect area, List points, DateTime minDate, DateTime maxDate, double minY, double maxY, - SKColor color, string label) + SKColor color, string label, + List<(float Y, string Text, SKColor Color)> rightLabels) { if (points.Count == 0) return; var last = points.Last(); float y = ValueToY((double)last.Performance, area, minY, maxY); y = Math.Max(area.Top, Math.Min(area.Bottom, y)); - - 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); + rightLabels.Add((y + 4, label, color)); } // ═══════════════════════════════════════════════════════════════════ @@ -393,7 +394,8 @@ public static class SkiaChartRendererV2 private static void DrawHorizontalLineWithLabel(SKCanvas canvas, SKRect area, double minY, double maxY, - float value, SKColor color, float thickness, bool dashed, string label) + float value, SKColor color, float thickness, bool dashed, string label, + List<(float Y, string Text, SKColor Color)> rightLabels) { float y = ValueToY(value, area, minY, maxY); if (y < area.Top || y > area.Bottom) return; @@ -407,10 +409,42 @@ public static class SkiaChartRendererV2 linePaint.PathEffect = SKPathEffect.CreateDash(new[] { 8f, 4f }, 0); canvas.DrawLine(area.Left, y, area.Right, y, linePaint); + rightLabels.Add((y + 4, label, color)); + } + + // ═══════════════════════════════════════════════════════════════════ + // Label destra — collision avoidance + // ═══════════════════════════════════════════════════════════════════ + + private static void DrawRightLabels(SKCanvas canvas, SKRect area, + List<(float Y, string Text, SKColor Color)> labels) + { + if (labels.Count == 0) return; + + const float minSpacing = 13f; + + var items = labels.OrderBy(l => l.Y).ToList(); + float[] ys = items.Select(l => l.Y).ToArray(); + + // Passata discendente: sposta in basso le label troppo vicine + for (int i = 1; i < ys.Length; i++) + if (ys[i] < ys[i - 1] + minSpacing) + ys[i] = ys[i - 1] + minSpacing; + + // Passata risalente: se qualcuna è finita sotto il bordo, riporta su + float maxAllowed = area.Bottom + 4f; + for (int i = ys.Length - 1; i >= 0; i--) + { + if (ys[i] > maxAllowed) ys[i] = maxAllowed; + maxAllowed = ys[i] - minSpacing; + } 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); + for (int i = 0; i < items.Count; i++) + { + using var textPaint = new SKPaint { Color = items[i].Color, IsAntialias = true }; + canvas.DrawText(items[i].Text, area.Right + 5, ys[i], SKTextAlign.Left, font, textPaint); + } } // ═══════════════════════════════════════════════════════════════════