feat(kpi): K12 — reusable KpiTrendChart SVG component
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
|
||||
@* M6 (K12) reusable KPI trend chart. Dependency-free inline SVG inside a
|
||||
Bootstrap card, styled to sit beside the AuditKpiTiles / SiteCallKpiTiles
|
||||
KPI cards (card + card-body, small text-muted labels, muted single stroke).
|
||||
Three states: a ≥2-point polyline chart, a single-sample note, and an
|
||||
em-dash placeholder for the unavailable / empty case. The coordinate math
|
||||
and number formatting live in the code-behind (InvariantCulture throughout). *@
|
||||
|
||||
<div class="card h-100" data-test="@DataTest">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-muted mb-2">@Title</h6>
|
||||
|
||||
@if (HasChart)
|
||||
{
|
||||
<svg viewBox="@ViewBox" preserveAspectRatio="none"
|
||||
role="img"
|
||||
style="width:100%; height:140px;"
|
||||
class="d-block">
|
||||
<title>@Title trend</title>
|
||||
|
||||
@* Baseline (x-axis) at value 0. *@
|
||||
<line x1="@PlotLeftXString" y1="@BaselineYString"
|
||||
x2="@PlotRightXString" y2="@BaselineYString"
|
||||
stroke="currentColor" stroke-opacity="0.25" stroke-width="1" />
|
||||
|
||||
@* The series itself — a single muted stroke, no fill. *@
|
||||
<polyline points="@BuildPoints()"
|
||||
fill="none"
|
||||
stroke="currentColor" stroke-opacity="0.7"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
||||
@* Value (left) + time (full width) labels beneath the chart. *@
|
||||
<div class="d-flex justify-content-between small text-muted mt-1">
|
||||
<span>max @MaxValueLabel</span>
|
||||
<span>min @BaselineLabel</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted">
|
||||
<span>@StartTimeLabel</span>
|
||||
<span>@EndTimeLabel</span>
|
||||
</div>
|
||||
}
|
||||
else if (HasSingleSample)
|
||||
{
|
||||
<div class="text-center text-muted py-4">
|
||||
<div class="h4 mb-1">@MaxValueLabel</div>
|
||||
<div class="small">Only one sample in range.</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Unavailable OR null/empty — em-dash placeholder, never an SVG. *@
|
||||
<div class="text-center py-4">
|
||||
<div class="display-6 text-muted mb-1">—</div>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="small text-muted">@ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable dependency-free SVG trend chart for M6 "KPI History & Trends"
|
||||
/// (Task K12). Renders a <see cref="KpiSeriesPoint"/> series as a single muted
|
||||
/// polyline inside a Bootstrap card, with a baseline (x-axis) and value/time
|
||||
/// labels. No third-party charting library — the project rule is custom Blazor
|
||||
/// components on Bootstrap CSS only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Purely presentational.</b> The component never fetches data; the parent
|
||||
/// page supplies a fresh <see cref="Points"/> list and an
|
||||
/// <see cref="IsAvailable"/> flag, mirroring how <c>AuditKpiTiles</c> and
|
||||
/// <c>SiteCallKpiTiles</c> consume a snapshot. The same card chrome and
|
||||
/// <c>small text-muted</c> label styling keep the chart visually consistent
|
||||
/// with the KPI tiles it sits beside.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Coordinate math.</b> X is normalised across the time range
|
||||
/// <c>[min(BucketStartUtc), max(BucketStartUtc)]</c>; Y is normalised across
|
||||
/// <c>[0, max(Value)]</c> with the baseline pinned at 0. Every guard that could
|
||||
/// otherwise divide by zero is explicit: an empty / single-point series never
|
||||
/// reaches the polyline math, an all-equal-timestamp series falls back to even
|
||||
/// horizontal spacing, and an all-zero (or single-distinct-value) series draws a
|
||||
/// flat line at the baseline. All coordinate doubles are formatted with
|
||||
/// <see cref="CultureInfo.InvariantCulture"/> so locales with comma decimals do
|
||||
/// not corrupt the SVG <c>points</c> attribute.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class KpiTrendChart
|
||||
{
|
||||
// The fixed user-space coordinate system the polyline is computed in. The
|
||||
// SVG is rendered with width="100%" + preserveAspectRatio="none" so it
|
||||
// scales responsively to its container while the math stays integer-clean.
|
||||
internal const int ViewBoxWidth = 600;
|
||||
internal const int ViewBoxHeight = 140;
|
||||
|
||||
// Inner plot inset so the polyline + baseline never touch the card edge and
|
||||
// the top sample is not clipped by the 1px stroke.
|
||||
private const double PadX = 4;
|
||||
private const double PadTop = 6;
|
||||
private const double PadBottom = 6;
|
||||
|
||||
/// <summary>
|
||||
/// The series to plot, assumed ordered ascending by
|
||||
/// <see cref="KpiSeriesPoint.BucketStartUtc"/>. <c>null</c> or empty renders
|
||||
/// the unavailable placeholder rather than an empty chart.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<KpiSeriesPoint>? Points { get; set; }
|
||||
|
||||
/// <summary>Card title — also used for the SVG <c><title></c> and the
|
||||
/// <c>data-test</c> slug. Required-ish; defaults to "Trend" if blank.</summary>
|
||||
[Parameter] public string Title { get; set; } = "Trend";
|
||||
|
||||
/// <summary>Optional unit suffix appended to value labels (e.g. "s").</summary>
|
||||
[Parameter] public string? Unit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the supplied series is a successful query result. False renders
|
||||
/// the em-dash placeholder (no SVG), mirroring the KPI tiles' unavailable
|
||||
/// state.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsAvailable { get; set; } = true;
|
||||
|
||||
/// <summary>Optional message rendered under the placeholder when
|
||||
/// <see cref="IsAvailable"/> is false.</summary>
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
|
||||
// ── Derived state ────────────────────────────────────────────────────────
|
||||
|
||||
private IReadOnlyList<KpiSeriesPoint> Series => Points ?? Array.Empty<KpiSeriesPoint>();
|
||||
|
||||
/// <summary>A drawable chart needs to be available and have ≥2 points.</summary>
|
||||
private bool HasChart => IsAvailable && Series.Count >= 2;
|
||||
|
||||
/// <summary>Exactly one sample — show the single-sample note, not a polyline.</summary>
|
||||
private bool HasSingleSample => IsAvailable && Series.Count == 1;
|
||||
|
||||
/// <summary>Available + ≥1 point but not a full chart, or unavailable / empty.</summary>
|
||||
private bool ShowPlaceholder => !IsAvailable || Series.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Stable Playwright hook: <c>kpi-trend-<slug></c> where the slug is the
|
||||
/// title lowercased with each run of non-alphanumerics collapsed to a single
|
||||
/// dash and trimmed. Empty / symbol-only titles fall back to "chart".
|
||||
/// </summary>
|
||||
private string DataTest => $"kpi-trend-{Slugify(Title)}";
|
||||
|
||||
internal static string Slugify(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return "chart";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(title.Length);
|
||||
var lastWasDash = false;
|
||||
foreach (var ch in title)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
sb.Append(char.ToLowerInvariant(ch));
|
||||
lastWasDash = false;
|
||||
}
|
||||
else if (!lastWasDash)
|
||||
{
|
||||
sb.Append('-');
|
||||
lastWasDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
var slug = sb.ToString().Trim('-');
|
||||
return slug.Length == 0 ? "chart" : slug;
|
||||
}
|
||||
|
||||
// ── Value/time range ─────────────────────────────────────────────────────
|
||||
|
||||
private double MaxValue => Series.Count == 0 ? 0 : Series.Max(p => p.Value);
|
||||
|
||||
private DateTime FirstUtc => Series[0].BucketStartUtc;
|
||||
private DateTime LastUtc => Series[^1].BucketStartUtc;
|
||||
|
||||
// ── Label formatting ─────────────────────────────────────────────────────
|
||||
|
||||
private string MaxValueLabel => FormatValue(MaxValue);
|
||||
private string BaselineLabel => FormatValue(0);
|
||||
|
||||
private string FormatValue(double v)
|
||||
{
|
||||
var num = v.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
return string.IsNullOrEmpty(Unit) ? num : $"{num} {Unit}";
|
||||
}
|
||||
|
||||
// Time labels: include the date prefix when the range spans more than a day,
|
||||
// otherwise just HH:mm. Always UTC (all timestamps in the system are UTC).
|
||||
private string StartTimeLabel => FormatTime(FirstUtc);
|
||||
private string EndTimeLabel => FormatTime(LastUtc);
|
||||
|
||||
private string FormatTime(DateTime utc)
|
||||
{
|
||||
var spansDays = Series.Count >= 2 && (LastUtc.Date != FirstUtc.Date);
|
||||
return utc.ToString(spansDays ? "MM-dd HH:mm" : "HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// ── Polyline geometry ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SVG <c>polyline points</c> string. Only called when
|
||||
/// <see cref="HasChart"/> is true (≥2 points). X spreads each point across
|
||||
/// the time range; if every timestamp is identical the range is zero and we
|
||||
/// fall back to even index-based spacing. Y is inverted (SVG origin is
|
||||
/// top-left) and normalised against <see cref="MaxValue"/>; a zero max draws
|
||||
/// every point on the baseline.
|
||||
/// </summary>
|
||||
private string BuildPoints()
|
||||
{
|
||||
var n = Series.Count;
|
||||
var plotW = ViewBoxWidth - (2 * PadX);
|
||||
var plotH = ViewBoxHeight - PadTop - PadBottom;
|
||||
var baselineY = ViewBoxHeight - PadBottom;
|
||||
|
||||
var firstTicks = FirstUtc.Ticks;
|
||||
double timeSpanTicks = LastUtc.Ticks - firstTicks;
|
||||
var max = MaxValue;
|
||||
|
||||
var sb = new StringBuilder(n * 12);
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var p = Series[i];
|
||||
|
||||
// X — by time fraction, or even index spacing when all timestamps equal.
|
||||
double xFrac = timeSpanTicks > 0
|
||||
? (p.BucketStartUtc.Ticks - firstTicks) / timeSpanTicks
|
||||
: (n == 1 ? 0.0 : (double)i / (n - 1));
|
||||
var x = PadX + (xFrac * plotW);
|
||||
|
||||
// Y — baseline at 0, top at max. Flat at baseline when max == 0.
|
||||
double yFrac = max > 0 ? p.Value / max : 0.0;
|
||||
var y = baselineY - (yFrac * plotH);
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
|
||||
sb.Append(',');
|
||||
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string BaselineYString =>
|
||||
(ViewBoxHeight - PadBottom).ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private string PlotRightXString =>
|
||||
(ViewBoxWidth - PadX).ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private string PlotLeftXString =>
|
||||
PadX.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private string ViewBox => $"0 0 {ViewBoxWidth} {ViewBoxHeight}";
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Bunit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="KpiTrendChart"/> (M6 K12) — the reusable
|
||||
/// dependency-free SVG trend chart. Coverage pins the three render states
|
||||
/// (multi-point chart, unavailable placeholder, single-sample note), the stable
|
||||
/// <c>data-test</c> slug, and the coordinate-math edge guards (all-equal
|
||||
/// timestamps, all-zero values) that must not divide by zero or throw.
|
||||
/// </summary>
|
||||
public class KpiTrendChartTests : BunitContext
|
||||
{
|
||||
private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static IReadOnlyList<KpiSeriesPoint> ThreePoints() => new[]
|
||||
{
|
||||
new KpiSeriesPoint(Base, 1.0),
|
||||
new KpiSeriesPoint(Base.AddMinutes(5), 4.0),
|
||||
new KpiSeriesPoint(Base.AddMinutes(10), 2.0),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Available_WithThreePoints_RendersPolylineTitleAndDataTest()
|
||||
{
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, ThreePoints())
|
||||
.Add(c => c.Title, "Queue Depth")
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var markup = cut.Markup;
|
||||
Assert.Contains("<polyline", markup);
|
||||
Assert.Contains("Queue Depth", markup);
|
||||
Assert.Contains("data-test=\"kpi-trend-queue-depth\"", markup);
|
||||
// The SVG must carry an accessible title.
|
||||
Assert.Contains("<title>", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Available_WithUnit_AppendsUnitToValueLabels()
|
||||
{
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, ThreePoints())
|
||||
.Add(c => c.Title, "Oldest Pending")
|
||||
.Add(c => c.Unit, "s")
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
// Max value label is "4 s" with the unit suffix applied.
|
||||
Assert.Contains("max 4 s", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unavailable_RendersEmDashPlaceholderAndError_NoPolyline()
|
||||
{
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, ThreePoints())
|
||||
.Add(c => c.Title, "Queue Depth")
|
||||
.Add(c => c.IsAvailable, false)
|
||||
.Add(c => c.ErrorMessage, "KPI query failed"));
|
||||
|
||||
var markup = cut.Markup;
|
||||
Assert.Contains("—", markup); // em dash
|
||||
Assert.Contains("KPI query failed", markup);
|
||||
Assert.DoesNotContain("<polyline", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullPoints_RendersPlaceholder_NoPolyline()
|
||||
{
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, (IReadOnlyList<KpiSeriesPoint>?)null)
|
||||
.Add(c => c.Title, "Stuck Count")
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var markup = cut.Markup;
|
||||
Assert.Contains("—", markup);
|
||||
Assert.DoesNotContain("<polyline", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinglePoint_RendersSingleSampleState_NoPolyline_DoesNotThrow()
|
||||
{
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, new[] { new KpiSeriesPoint(Base, 7.0) })
|
||||
.Add(c => c.Title, "Backlog")
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var markup = cut.Markup;
|
||||
Assert.Contains("Only one sample in range.", markup);
|
||||
Assert.DoesNotContain("<polyline", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEqualTimestamps_DoesNotThrow_AndRendersPolyline()
|
||||
{
|
||||
// Zero time span would divide by zero without the even-spacing guard.
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, new[]
|
||||
{
|
||||
new KpiSeriesPoint(Base, 1.0),
|
||||
new KpiSeriesPoint(Base, 2.0),
|
||||
new KpiSeriesPoint(Base, 3.0),
|
||||
})
|
||||
.Add(c => c.Title, "Flat Time")
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
Assert.Contains("<polyline", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllZeroValues_DoesNotThrow_AndRendersFlatPolyline()
|
||||
{
|
||||
// Zero max value would divide by zero without the flat-line guard.
|
||||
var cut = Render<KpiTrendChart>(p => p
|
||||
.Add(c => c.Points, new[]
|
||||
{
|
||||
new KpiSeriesPoint(Base, 0.0),
|
||||
new KpiSeriesPoint(Base.AddMinutes(5), 0.0),
|
||||
new KpiSeriesPoint(Base.AddMinutes(10), 0.0),
|
||||
})
|
||||
.Add(c => c.Title, "Quiet")
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
Assert.Contains("<polyline", cut.Markup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Queue Depth", "kpi-trend-queue-depth")]
|
||||
[InlineData("Oldest Pending (s)", "kpi-trend-oldest-pending-s")]
|
||||
[InlineData(" Trailing Spaces ", "kpi-trend-trailing-spaces")]
|
||||
[InlineData("!!!", "kpi-trend-chart")]
|
||||
[InlineData("", "kpi-trend-chart")]
|
||||
public void Slugify_ProducesStableSlug(string title, string expectedDataTest)
|
||||
{
|
||||
Assert.Equal(expectedDataTest, $"kpi-trend-{KpiTrendChart.Slugify(title)}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user