using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using HC_APTBS.Models; namespace HC_APTBS.Services.Impl { /// /// Generates SVG chart markup for embedding in QuestPDF reports via the /// .Svg() API. All rendering is done as raw SVG strings — no /// SkiaSharp dependency required at PDF-generation time. /// internal static class ReportChartRenderer { // ── Layout constants (SVG user-units, mapped to points in the PDF) ──── private const float LeftMargin = 48f; private const float RightMargin = 12f; private const float TopMargin = 22f; private const float BottomMargin = 24f; private const float LegendHeight = 14f; // ── Public API ──────────────────────────────────────────────────────── /// /// Produces an SVG string depicting a line chart of individual /// measurement samples with a horizontal target line, shaded tolerance /// band, average line, and axis labels. /// /// Chart width in points. /// Chart height in points. /// Individual timestamped measurements. /// Expected / setpoint value. /// Acceptable deviation (±). /// Computed average of all samples. /// Whether the parameter passed the tolerance check. /// Chart title label (e.g. "1000 RPM 40°C — QDelivery"). /// A complete SVG document string. public static string RenderMeasurementChart( float width, float height, IReadOnlyList samples, double targetValue, double tolerance, double average, bool passed, string label) { if (samples.Count < 2) return RenderInsufficientData(width, height, label); var sb = new StringBuilder(); sb.AppendLine(SvgHeader(width, height)); // Plot area bounds. float plotLeft = LeftMargin; float plotRight = width - RightMargin; float plotTop = TopMargin + LegendHeight; float plotBottom = height - BottomMargin; float plotW = plotRight - plotLeft; float plotH = plotBottom - plotTop; // Y-axis range: encompass all samples and the tolerance band, with padding. double minVal = samples.Min(s => s.Value); double maxVal = samples.Max(s => s.Value); double bandLo = targetValue - tolerance; double bandHi = targetValue + tolerance; minVal = Math.Min(minVal, bandLo); maxVal = Math.Max(maxVal, bandHi); double range = maxVal - minVal; if (range < 0.001) range = 1.0; double padding = range * 0.12; double yMin = minVal - padding; double yMax = maxVal + padding; double yRange = yMax - yMin; // ── Background & border ────────────────────────────────────────── sb.AppendLine(Rect(0, 0, width, height, "#FFFFFF", null, 0)); sb.AppendLine(Rect(plotLeft, plotTop, plotW, plotH, null, Col(ReportTheme.ChartGrid), 1)); // ── Grid lines ─────────────────────────────────────────────────── const int gridCount = 5; double gridStep = yRange / gridCount; for (int i = 0; i <= gridCount; i++) { double val = yMin + gridStep * i; float y = ValueToY(val, plotTop, plotH, yMin, yRange); sb.AppendLine(Line(plotLeft, y, plotRight, y, Col(ReportTheme.ChartGrid), 0.5f, null)); string text = val.ToString("F1", CultureInfo.InvariantCulture); sb.AppendLine(Text(plotLeft - 4f, y + 3f, text, Col(ReportTheme.AxisLabel), 7f, "end")); } // ── Tolerance band ─────────────────────────────────────────────── float bandTopY = Math.Max(ValueToY(bandHi, plotTop, plotH, yMin, yRange), plotTop); float bandBotY = Math.Min(ValueToY(bandLo, plotTop, plotH, yMin, yRange), plotBottom); sb.AppendLine(Rect(plotLeft, bandTopY, plotW, bandBotY - bandTopY, ColAlpha(ReportTheme.ToleranceBand), null, 0)); // ── Target line (dashed amber) ─────────────────────────────────── float targetY = ValueToY(targetValue, plotTop, plotH, yMin, yRange); sb.AppendLine(Line(plotLeft, targetY, plotRight, targetY, Col(ReportTheme.TargetLine), 1.5f, "6,3")); // ── Average line (dashed green/red) ────────────────────────────── float avgY = ValueToY(average, plotTop, plotH, yMin, yRange); string avgCol = passed ? Col(ReportTheme.AvgPassLine) : Col(ReportTheme.AvgFailLine); sb.AppendLine(Line(plotLeft, avgY, plotRight, avgY, avgCol, 1.2f, "4,3")); // ── Data polyline ──────────────────────────────────────────────── var points = new StringBuilder(); for (int i = 0; i < samples.Count; i++) { float x = plotLeft + (plotW * i / (samples.Count - 1)); float y = ValueToY(samples[i].Value, plotTop, plotH, yMin, yRange); if (i > 0) points.Append(' '); points.Append(F(x)).Append(',').Append(F(y)); } sb.Append($""); // ── Sample dots (only if ≤ 60 samples) ────────────────────────── if (samples.Count <= 60) { for (int i = 0; i < samples.Count; i++) { float x = plotLeft + (plotW * i / (samples.Count - 1)); float y = ValueToY(samples[i].Value, plotTop, plotH, yMin, yRange); sb.AppendLine($""); } } // ── X-axis labels ──────────────────────────────────────────────── int xLabelStep = Math.Max(1, samples.Count / 8); for (int i = 0; i < samples.Count; i += xLabelStep) { float x = plotLeft + (plotW * i / (samples.Count - 1)); sb.AppendLine(Text(x, plotBottom + 12f, (i + 1).ToString(), Col(ReportTheme.AxisLabel), 7f, "middle")); } // ── Title label (top-left) ─────────────────────────────────────── sb.AppendLine(Text(plotLeft, TopMargin, EscapeXml(label), "#1B2A4A", 9f, "start")); // ── Legend (top-right) ──────────────────────────────────────────── string targetText = $"Target: {targetValue.ToString("F2", CultureInfo.InvariantCulture)}"; string avgText = $"Avg: {average.ToString("F2", CultureInfo.InvariantCulture)}"; sb.AppendLine(Text(plotRight - 80f, TopMargin, targetText, Col(ReportTheme.TargetLine), 7f, "start")); sb.AppendLine(Text(plotRight, TopMargin, avgText, avgCol, 7f, "end")); sb.AppendLine(""); return sb.ToString(); } /// /// Produces an SVG string depicting a large PASS/FAIL verdict badge. /// public static string RenderVerdictBadge(float width, float height, bool passed) { var sb = new StringBuilder(); sb.AppendLine(SvgHeader(width, height)); string bgColor = passed ? "#2E7D32" : "#C62828"; sb.AppendLine($""); string text = passed ? "PASS" : "FAIL"; float fontSize = height * 0.45f; sb.AppendLine($"{text}"); sb.AppendLine(""); return sb.ToString(); } // ── Private helpers ─────────────────────────────────────────────────── /// Maps a data value to the Y coordinate (inverted — higher values = lower Y). private static float ValueToY(double value, float plotTop, float plotH, double yMin, double yRange) => plotTop + (float)((1.0 - (value - yMin) / yRange) * plotH); /// Renders a placeholder SVG when fewer than 2 samples are available. private static string RenderInsufficientData(float width, float height, string label) { var sb = new StringBuilder(); sb.AppendLine(SvgHeader(width, height)); sb.AppendLine(Rect(0, 0, width, height, "#F5F7FA", Col(ReportTheme.ChartGrid), 1)); sb.AppendLine(Text(12f, 18f, EscapeXml(label), "#1B2A4A", 9f, "start")); sb.AppendLine(Text(width / 2, height / 2 + 4f, "Insufficient sample data for chart", Col(ReportTheme.AxisLabel), 10f, "middle")); sb.AppendLine(""); return sb.ToString(); } // ── SVG primitives ──────────────────────────────────────────────────── /// SVG document header. private static string SvgHeader(float w, float h) => $""; /// SVG rect element with optional fill and/or stroke. private static string Rect(float x, float y, float w, float h, string? fill, string? stroke, float strokeWidth) { var sb = new StringBuilder(); sb.Append($""); return sb.ToString(); } /// SVG line element with optional dash array. private static string Line(float x1, float y1, float x2, float y2, string stroke, float strokeWidth, string? dashArray) { var sb = new StringBuilder(); sb.Append($""); return sb.ToString(); } /// SVG text element. private static string Text(float x, float y, string content, string fill, float fontSize, string anchor) => $"{content}"; /// Formats a float with invariant culture (dot decimal separator). private static string F(float v) => v.ToString("F1", CultureInfo.InvariantCulture); /// Converts an SKColor to an SVG hex colour string. private static string Col(SkiaSharp.SKColor c) => $"#{c.Red:X2}{c.Green:X2}{c.Blue:X2}"; /// Converts an SKColor with alpha to an SVG rgba() string. private static string ColAlpha(SkiaSharp.SKColor c) => $"rgba({c.Red},{c.Green},{c.Blue},{(c.Alpha / 255.0).ToString("F2", CultureInfo.InvariantCulture)})"; /// Escapes XML special characters in text content. private static string EscapeXml(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">") .Replace("\"", """).Replace("\u2014", "-"); } }