feat: add FundSkiaChartRenderer (SkiaSharp price line chart)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:44:50 +02:00
parent 8e6341d980
commit 7dbcb8ab16

View File

@@ -0,0 +1,188 @@
using CertReports.Syncfusion.Models;
using SkiaSharp;
namespace CertReports.Syncfusion.Services.Implementations;
public static class FundSkiaChartRenderer
{
private const int DefaultWidth = 1100;
private const int DefaultHeight = 700;
private const float MarginLeft = 70f;
private const float MarginRight = 40f;
private const float MarginTop = 55f;
private const float MarginBottom = 55f;
public static byte[] Render(List<FundChartPoint> points, string instrumentName,
int width = DefaultWidth, int height = DefaultHeight, bool jpeg = false)
{
using var surface = SKSurface.Create(new SKImageInfo(width, height));
var canvas = surface.Canvas;
canvas.Clear(SKColors.White);
if (points.Count < 2)
DrawNoDataMessage(canvas, width, height, instrumentName);
else
DrawChart(canvas, points, instrumentName, width, height);
using var image = surface.Snapshot();
using var data = jpeg
? image.Encode(SKEncodedImageFormat.Jpeg, 90)
: image.Encode(SKEncodedImageFormat.Png, 100);
return data.ToArray();
}
private static void DrawChart(SKCanvas canvas, List<FundChartPoint> points,
string title, int width, int height)
{
float plotX = MarginLeft;
float plotY = MarginTop;
float plotW = width - MarginLeft - MarginRight;
float plotH = height - MarginTop - MarginBottom;
decimal minPrice = points.Min(p => p.Close);
decimal maxPrice = points.Max(p => p.Close);
decimal priceRange = maxPrice - minPrice;
if (priceRange == 0) priceRange = 1;
decimal pad = priceRange * 0.08m;
decimal yMin = minPrice - pad;
decimal yMax = maxPrice + pad;
var minDate = points.First().Date;
var maxDate = points.Last().Date;
double totalDays = (maxDate - minDate).TotalDays;
// Plot area background
using var bgPaint = new SKPaint { Color = new SKColor(250, 250, 255), Style = SKPaintStyle.Fill };
canvas.DrawRect(plotX, plotY, plotW, plotH, bgPaint);
DrawGrid(canvas, plotX, plotY, plotW, plotH, yMin, yMax, minDate, maxDate, totalDays);
// Price line
using var linePaint = new SKPaint
{
Color = SKColors.Black,
StrokeWidth = 2f,
IsAntialias = true,
Style = SKPaintStyle.Stroke
};
using var path = new SKPath();
bool first = true;
foreach (var pt in points)
{
float px = DateToX(pt.Date, minDate, maxDate, plotX, plotW);
float py = PriceToY(pt.Close, yMin, yMax, plotY, plotH);
if (first) { path.MoveTo(px, py); first = false; }
else path.LineTo(px, py);
}
canvas.DrawPath(path, linePaint);
// End label (last price)
var last = points.Last();
float lastX = DateToX(last.Date, minDate, maxDate, plotX, plotW);
float lastY = PriceToY(last.Close, yMin, yMax, plotY, plotH);
var labelFont = new SKFont(SKTypeface.Default, 11f);
using var labelPaint = new SKPaint { Color = SKColors.Black, IsAntialias = true };
canvas.DrawText($"{last.Close:F2}", lastX + 4f, lastY + 4f,
SKTextAlign.Left, labelFont, labelPaint);
// Chart title
var titleFont = new SKFont(SKTypeface.Default, 14f) { Embolden = true };
using var titlePaint = new SKPaint
{
Color = new SKColor(21, 101, 192),
IsAntialias = true
};
canvas.DrawText(title, (float)width / 2, MarginTop - 10f,
SKTextAlign.Center, titleFont, titlePaint);
// Plot border
using var borderPaint = new SKPaint
{
Color = new SKColor(200, 200, 200),
StrokeWidth = 0.8f,
Style = SKPaintStyle.Stroke
};
canvas.DrawRect(plotX, plotY, plotW, plotH, borderPaint);
}
private static void DrawGrid(SKCanvas canvas,
float plotX, float plotY, float plotW, float plotH,
decimal yMin, decimal yMax,
DateTime minDate, DateTime maxDate, double totalDays)
{
using var gridPaint = new SKPaint
{
Color = new SKColor(220, 220, 220),
StrokeWidth = 0.5f,
Style = SKPaintStyle.Stroke
};
using var axisPaint = new SKPaint
{
Color = new SKColor(100, 100, 100),
StrokeWidth = 0.8f,
Style = SKPaintStyle.Stroke
};
using var axisLabelPaint = new SKPaint
{
Color = new SKColor(100, 100, 100),
IsAntialias = true
};
var axisLabelFont = new SKFont(SKTypeface.Default, 10f);
// Y grid (5 horizontal lines)
for (int i = 0; i <= 5; i++)
{
decimal price = yMin + (yMax - yMin) * i / 5;
float py = PriceToY(price, yMin, yMax, plotY, plotH);
canvas.DrawLine(plotX, py, plotX + plotW, py, i == 0 ? axisPaint : gridPaint);
canvas.DrawText($"{price:F2}", plotX - 5f, py + 4f,
SKTextAlign.Right, axisLabelFont, axisLabelPaint);
}
// X grid (adaptive monthly intervals)
int monthInterval = totalDays > 365 * 3 ? 12 :
totalDays > 365 ? 6 :
totalDays > 90 ? 3 : 1;
var current = new DateTime(minDate.Year, minDate.Month, 1).AddMonths(1);
while (current <= maxDate)
{
if (current.Month % monthInterval == 0)
{
float px = DateToX(current, minDate, maxDate, plotX, plotW);
canvas.DrawLine(px, plotY, px, plotY + plotH, gridPaint);
var lbl = monthInterval >= 12
? current.Year.ToString()
: current.ToString("MMM yy");
canvas.DrawText(lbl, px, plotY + plotH + 14f,
SKTextAlign.Center, axisLabelFont, axisLabelPaint);
}
current = current.AddMonths(1);
}
}
private static void DrawNoDataMessage(SKCanvas canvas, int width, int height, string title)
{
var font = new SKFont(SKTypeface.Default, 14f);
using var paint = new SKPaint { Color = new SKColor(150, 150, 150), IsAntialias = true };
canvas.DrawText($"{title} — Dati insufficienti",
(float)width / 2, (float)height / 2, SKTextAlign.Center, font, paint);
}
private static float DateToX(DateTime date, DateTime minDate, DateTime maxDate,
float plotX, float plotW)
{
double totalDays = (maxDate - minDate).TotalDays;
if (totalDays == 0) return plotX;
double ratio = (date - minDate).TotalDays / totalDays;
return plotX + (float)(ratio * plotW);
}
private static float PriceToY(decimal price, decimal yMin, decimal yMax,
float plotY, float plotH)
{
if (yMax == yMin) return plotY + plotH / 2;
double ratio = (double)((price - yMin) / (yMax - yMin));
return plotY + plotH - (float)(ratio * plotH);
}
}