Config system fixes: - Implement SavePump() — full XML serialization with insert/update by pump ID - Add CanBusParameter.ToPumpXml() for legacy P1-P6 pump param format - Fix LastRotationDirection never loaded in LoadSettings() - Add SaveAlarms() to ConfigurationService and IConfigurationService - Remove dead fields AppSettings.Clients and AppSettings.PumpIds PDF report redesign: - Professional layout with charts, verdict badges, and tolerance bands - Add ReportChartRenderer (SVG) and ReportTheme styling constants - Embed default_logo.png as fallback report logo Documentation: - Add gap analysis docs (config validation, ford unlock, missing features) - Update CLAUDE.md architecture, known gaps, and debt tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
14 KiB
C#
265 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using HC_APTBS.Models;
|
|
|
|
namespace HC_APTBS.Services.Impl
|
|
{
|
|
/// <summary>
|
|
/// Generates SVG chart markup for embedding in QuestPDF reports via the
|
|
/// <c>.Svg()</c> API. All rendering is done as raw SVG strings — no
|
|
/// SkiaSharp dependency required at PDF-generation time.
|
|
/// </summary>
|
|
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 ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="width">Chart width in points.</param>
|
|
/// <param name="height">Chart height in points.</param>
|
|
/// <param name="samples">Individual timestamped measurements.</param>
|
|
/// <param name="targetValue">Expected / setpoint value.</param>
|
|
/// <param name="tolerance">Acceptable deviation (±).</param>
|
|
/// <param name="average">Computed average of all samples.</param>
|
|
/// <param name="passed">Whether the parameter passed the tolerance check.</param>
|
|
/// <param name="label">Chart title label (e.g. "1000 RPM 40°C — QDelivery").</param>
|
|
/// <returns>A complete SVG document string.</returns>
|
|
public static string RenderMeasurementChart(
|
|
float width,
|
|
float height,
|
|
IReadOnlyList<MeasurementSample> 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($"<polyline points=\"{points}\" fill=\"none\" ")
|
|
.Append($"stroke=\"{Col(ReportTheme.SampleLine)}\" stroke-width=\"1.5\" ")
|
|
.AppendLine($"stroke-linejoin=\"round\"/>");
|
|
|
|
// ── 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($"<circle cx=\"{F(x)}\" cy=\"{F(y)}\" r=\"2\" " +
|
|
$"fill=\"{Col(ReportTheme.SampleLine)}\"/>");
|
|
}
|
|
}
|
|
|
|
// ── 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("</svg>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Produces an SVG string depicting a large PASS/FAIL verdict badge.
|
|
/// </summary>
|
|
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($"<rect x=\"2\" y=\"2\" width=\"{F(width - 4)}\" height=\"{F(height - 4)}\" " +
|
|
$"rx=\"8\" ry=\"8\" fill=\"{bgColor}\"/>");
|
|
|
|
string text = passed ? "PASS" : "FAIL";
|
|
float fontSize = height * 0.45f;
|
|
sb.AppendLine($"<text x=\"{F(width / 2)}\" y=\"{F(height / 2 + fontSize * 0.35f)}\" " +
|
|
$"font-family=\"Arial\" font-weight=\"bold\" font-size=\"{F(fontSize)}\" " +
|
|
$"fill=\"white\" text-anchor=\"middle\">{text}</text>");
|
|
|
|
sb.AppendLine("</svg>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────
|
|
|
|
/// <summary>Maps a data value to the Y coordinate (inverted — higher values = lower Y).</summary>
|
|
private static float ValueToY(double value, float plotTop, float plotH, double yMin, double yRange)
|
|
=> plotTop + (float)((1.0 - (value - yMin) / yRange) * plotH);
|
|
|
|
/// <summary>Renders a placeholder SVG when fewer than 2 samples are available.</summary>
|
|
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("</svg>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ── SVG primitives ────────────────────────────────────────────────────
|
|
|
|
/// <summary>SVG document header.</summary>
|
|
private static string SvgHeader(float w, float h)
|
|
=> $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{F(w)}\" height=\"{F(h)}\" " +
|
|
$"viewBox=\"0 0 {F(w)} {F(h)}\">";
|
|
|
|
/// <summary>SVG rect element with optional fill and/or stroke.</summary>
|
|
private static string Rect(float x, float y, float w, float h,
|
|
string? fill, string? stroke, float strokeWidth)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append($"<rect x=\"{F(x)}\" y=\"{F(y)}\" width=\"{F(w)}\" height=\"{F(h)}\"");
|
|
sb.Append(fill != null ? $" fill=\"{fill}\"" : " fill=\"none\"");
|
|
if (stroke != null) sb.Append($" stroke=\"{stroke}\" stroke-width=\"{F(strokeWidth)}\"");
|
|
sb.Append("/>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>SVG line element with optional dash array.</summary>
|
|
private static string Line(float x1, float y1, float x2, float y2,
|
|
string stroke, float strokeWidth, string? dashArray)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append($"<line x1=\"{F(x1)}\" y1=\"{F(y1)}\" x2=\"{F(x2)}\" y2=\"{F(y2)}\" ");
|
|
sb.Append($"stroke=\"{stroke}\" stroke-width=\"{F(strokeWidth)}\"");
|
|
if (dashArray != null) sb.Append($" stroke-dasharray=\"{dashArray}\"");
|
|
sb.Append("/>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>SVG text element.</summary>
|
|
private static string Text(float x, float y, string content,
|
|
string fill, float fontSize, string anchor)
|
|
=> $"<text x=\"{F(x)}\" y=\"{F(y)}\" font-family=\"Arial\" font-size=\"{F(fontSize)}\" " +
|
|
$"fill=\"{fill}\" text-anchor=\"{anchor}\">{content}</text>";
|
|
|
|
/// <summary>Formats a float with invariant culture (dot decimal separator).</summary>
|
|
private static string F(float v) => v.ToString("F1", CultureInfo.InvariantCulture);
|
|
|
|
/// <summary>Converts an SKColor to an SVG hex colour string.</summary>
|
|
private static string Col(SkiaSharp.SKColor c) => $"#{c.Red:X2}{c.Green:X2}{c.Blue:X2}";
|
|
|
|
/// <summary>Converts an SKColor with alpha to an SVG rgba() string.</summary>
|
|
private static string ColAlpha(SkiaSharp.SKColor c)
|
|
=> $"rgba({c.Red},{c.Green},{c.Blue},{(c.Alpha / 255.0).ToString("F2", CultureInfo.InvariantCulture)})";
|
|
|
|
/// <summary>Escapes XML special characters in text content.</summary>
|
|
private static string EscapeXml(string s)
|
|
=> s.Replace("&", "&").Replace("<", "<").Replace(">", ">")
|
|
.Replace("\"", """).Replace("\u2014", "-");
|
|
}
|
|
}
|