File: Components\Controls\Chart\SparklinePath.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text;
 
namespace Aspire.Dashboard.Components.Controls.Chart;
 
/// <summary>
/// Builds SVG path data for small inline sparklines, as a line or as bars.
/// </summary>
public static class SparklinePath
{
    // Keeps strokes from being clipped at the top and bottom edges of the SVG.
    internal const double Inset = 2;
 
    /// <summary>
    /// Builds a smoothed line path and a matching closed area path for the values.
    /// Values are spaced evenly across <paramref name="width"/>. Null values create gaps.
    /// Returns empty strings when there is nothing to draw.
    /// </summary>
    public static (string Line, string Area) Build(IReadOnlyList<double?> values, double width, double height)
    {
        if (values.Count < 2 || !TryCreateScale(values, height, out var scale))
        {
            return (string.Empty, string.Empty);
        }
 
        var step = width / (values.Count - 1);
        var zeroY = scale.ScaleY(0);
        var line = new StringBuilder();
        var area = new StringBuilder();
        var segment = new List<(double X, double Y)>();
 
        for (var i = 0; i <= values.Count; i++)
        {
            if (i < values.Count && values[i] is { } v)
            {
                segment.Add((i * step, scale.ScaleY(v)));
                continue;
            }
 
            if (segment.Count > 0)
            {
                AppendSegment(line, area, segment, zeroY);
                segment.Clear();
            }
        }
 
        return (line.ToString(), area.ToString());
    }
 
    /// <summary>
    /// Builds a path of vertical bars, one per value, rising from zero. Null and zero values draw no bar.
    /// Returns an empty string when there is nothing to draw.
    /// </summary>
    public static string BuildBars(IReadOnlyList<double?> values, double width, double height)
    {
        if (values.Count == 0 || !TryCreateScale(values, height, out var scale))
        {
            return string.Empty;
        }
 
        // Each value gets an equal slot; the gap between bars keeps them readable as separate
        // intervals at sparkline size instead of merging into a solid block.
        var slot = width / values.Count;
        var barWidth = slot * 0.7;
        var zeroY = scale.ScaleY(0);
        var bars = new StringBuilder();
 
        for (var i = 0; i < values.Count; i++)
        {
            if (values[i] is not { } v || v == 0)
            {
                continue;
            }
 
            // A tiny value next to a large one would scale below a pixel and disappear, which
            // would look the same as an interval with no activity. Keep a minimum visible height,
            // growing away from the zero line.
            const double MinBarHeight = 1.5;
            var valueY = scale.ScaleY(v);
            var top = v > 0 ? Math.Min(valueY, zeroY - MinBarHeight) : zeroY;
            var bottom = v > 0 ? zeroY : Math.Max(valueY, zeroY + MinBarHeight);
 
            var x = (i * slot) + ((slot - barWidth) / 2);
            bars.Append(CultureInfo.InvariantCulture, $"M{x:0.##},{bottom:0.##}V{top:0.##}H{x + barWidth:0.##}V{bottom:0.##}Z");
        }
 
        return bars.ToString();
    }
 
    // Sparklines start at zero, like the full chart. A flat 100 then sits at the top and a flat 0
    // on the baseline, rather than both drawing at the same height, and small changes on a large
    // value read as small. Negative values (possible for up-down counters) extend the range below zero.
    private static bool TryCreateScale(IReadOnlyList<double?> values, double height, out Scale scale)
    {
        double? min = null;
        double? max = null;
        foreach (var value in values)
        {
            if (value is { } v)
            {
                min = min is null ? v : Math.Min(min.Value, v);
                max = max is null ? v : Math.Max(max.Value, v);
            }
        }
 
        if (min is null || max is null)
        {
            scale = default;
            return false;
        }
 
        scale = new Scale(Math.Min(0, min.Value), Math.Max(0, max.Value), height);
        return true;
    }
 
    private readonly record struct Scale(double Min, double Max, double Height)
    {
        // An all-zero series has no range; it draws on the baseline.
        public double ScaleY(double value) => Max == Min
            ? Height - Inset
            : Inset + ((Height - (2 * Inset)) * (1 - ((value - Min) / (Max - Min))));
    }
 
    private static void AppendSegment(StringBuilder line, StringBuilder area, List<(double X, double Y)> points, double zeroY)
    {
        var curve = new StringBuilder();
        curve.Append(CultureInfo.InvariantCulture, $"M{points[0].X:0.##},{points[0].Y:0.##}");
 
        if (points.Count == 1)
        {
            // A lone point has no length to stroke, so draw a short horizontal tick for it.
            curve.Append(CultureInfo.InvariantCulture, $"h0.5");
        }
        else
        {
            var tangents = CalculateMonotoneTangents(points);
            for (var i = 0; i < points.Count - 1; i++)
            {
                var (x0, y0) = points[i];
                var (x1, y1) = points[i + 1];
                var dx = (x1 - x0) / 3;
                curve.Append(CultureInfo.InvariantCulture,
                    $"C{x0 + dx:0.##},{y0 + (dx * tangents[i]):0.##} {x1 - dx:0.##},{y1 - (dx * tangents[i + 1]):0.##} {x1:0.##},{y1:0.##}");
            }
        }
 
        line.Append(curve);
        area.Append(curve);
        area.Append(CultureInfo.InvariantCulture, $"L{points[^1].X:0.##},{zeroY:0.##}L{points[0].X:0.##},{zeroY:0.##}Z");
    }
 
    // Monotone cubic interpolation (Fritsch–Carlson, as used by d3's curveMonotoneX).
    // Unlike Catmull-Rom splines it never overshoots the data, so a flat run of zeros stays
    // flat and a step change doesn't produce a misleading bump above or below the real values.
    // See https://en.wikipedia.org/wiki/Monotone_cubic_interpolation and
    // https://github.com/d3/d3-shape/blob/main/src/curve/monotone.js
    private static double[] CalculateMonotoneTangents(List<(double X, double Y)> points)
    {
        var n = points.Count;
        var slopes = new double[n - 1];
        for (var i = 0; i < n - 1; i++)
        {
            slopes[i] = (points[i + 1].Y - points[i].Y) / (points[i + 1].X - points[i].X);
        }
 
        var tangents = new double[n];
        tangents[0] = slopes[0];
        tangents[n - 1] = slopes[n - 2];
 
        for (var i = 1; i < n - 1; i++)
        {
            var s0 = slopes[i - 1];
            var s1 = slopes[i];
            if (s0 * s1 <= 0)
            {
                // Local extremum or flat section: a zero tangent prevents overshoot.
                tangents[i] = 0;
                continue;
            }
 
            var h0 = points[i].X - points[i - 1].X;
            var h1 = points[i + 1].X - points[i].X;
            var p = ((s0 * h1) + (s1 * h0)) / (h0 + h1);
            tangents[i] = Math.Sign(s0) * Math.Min(Math.Min(Math.Abs(s0), Math.Abs(s1)), 0.5 * Math.Abs(p)) * 2;
        }
 
        return tangents;
    }
}