Add breakdown chart support

This also cleans up the bar chart code slightly and fixes
some minor bugs that were detected in related code.

Closes #244
This commit is contained in:
Patrik Svensson
2021-01-31 22:46:15 +01:00
committed by Patrik Svensson
parent 58400fe74e
commit b64e016e8c
32 changed files with 911 additions and 41 deletions

View File

@ -22,7 +22,7 @@ namespace Spectre.Console
private TableBorder _border;
private bool _useSafeBorder;
private Style? _borderStyle;
private CultureInfo _culture;
private CultureInfo? _culture;
private Style _highlightStyle;
private bool _showHeader;
private Style? _headerStyle;
@ -79,7 +79,7 @@ namespace Spectre.Console
/// <summary>
/// Gets or sets the calendar's <see cref="CultureInfo"/>.
/// </summary>
public CultureInfo Culture
public CultureInfo? Culture
{
get => _culture;
set => MarkAsDirty(() => _culture = value);

View File

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Spectre.Console.Rendering;
@ -7,12 +9,12 @@ namespace Spectre.Console
/// <summary>
/// A renderable (horizontal) bar chart.
/// </summary>
public sealed class BarChart : Renderable
public sealed class BarChart : Renderable, IHasCulture
{
/// <summary>
/// Gets the bar chart data.
/// </summary>
public List<BarChartItem> Data { get; }
public List<IBarChartItem> Data { get; }
/// <summary>
/// Gets or sets the width of the bar chart.
@ -35,24 +37,38 @@ namespace Spectre.Console
/// </summary>
public bool ShowValues { get; set; } = true;
/// <summary>
/// Gets or sets the culture that's used to format values.
/// </summary>
/// <remarks>Defaults to invariant culture.</remarks>
public CultureInfo? Culture { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="BarChart"/> class.
/// </summary>
public BarChart()
{
Data = new List<BarChartItem>();
Data = new List<IBarChartItem>();
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
return new Measurement(width, width);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
var maxValue = Data.Max(item => item.Value);
var grid = new Grid();
grid.Collapse();
grid.AddColumn(new GridColumn().PadRight(2).RightAligned());
grid.AddColumn(new GridColumn().PadLeft(0));
grid.Width = Width;
grid.Width = width;
if (!string.IsNullOrWhiteSpace(Label))
{
@ -73,10 +89,11 @@ namespace Spectre.Console
UnicodeBar = '█',
AsciiBar = '█',
ShowValue = ShowValues,
Culture = Culture,
});
}
return ((IRenderable)grid).Render(context, maxWidth);
return ((IRenderable)grid).Render(context, width);
}
}
}

View File

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class BreakdownBar : Renderable
{
private readonly List<IBreakdownChartItem> _data;
public int? Width { get; set; }
public BreakdownBar(List<IBreakdownChartItem> data)
{
_data = data ?? throw new ArgumentNullException(nameof(data));
}
protected override Measurement Measure(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
return new Measurement(width, width);
}
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
// Chart
var maxValue = _data.Sum(i => i.Value);
var items = _data.ToArray();
var bars = Ratio.Distribute(width, items.Select(i => Math.Max(0, (int)(width * (i.Value / maxValue)))).ToArray());
for (var index = 0; index < items.Length; index++)
{
yield return new Segment(new string('█', bars[index]), new Style(items[index].Color));
}
yield return Segment.LineBreak;
}
}
}

View File

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A renderable breakdown chart.
/// </summary>
public sealed class BreakdownChart : Renderable, IHasCulture
{
/// <summary>
/// Gets the breakdown chart data.
/// </summary>
public List<IBreakdownChartItem> Data { get; }
/// <summary>
/// Gets or sets the width of the breakdown chart.
/// </summary>
public int? Width { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not
/// to show values as percentages or not.
/// </summary>
public bool ShowAsPercentages { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not to show tags.
/// </summary>
public bool ShowTags { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not to show tag values.
/// </summary>
public bool ShowTagValues { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether or not the
/// chart and tags should be rendered in compact mode.
/// </summary>
public bool Compact { get; set; } = true;
/// <summary>
/// Gets or sets the <see cref="CultureInfo"/> to use
/// when rendering values.
/// </summary>
/// <remarks>Defaults to invariant culture.</remarks>
public CultureInfo? Culture { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="BreakdownChart"/> class.
/// </summary>
public BreakdownChart()
{
Data = new List<IBreakdownChartItem>();
Culture = CultureInfo.InvariantCulture;
}
/// <inheritdoc/>
protected override Measurement Measure(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
return new Measurement(width, width);
}
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
var grid = new Grid().Width(width);
grid.AddColumn(new GridColumn().NoWrap());
// Bar
grid.AddRow(new BreakdownBar(Data)
{
Width = width,
});
if (ShowTags)
{
if (!Compact)
{
grid.AddEmptyRow();
}
// Tags
grid.AddRow(new BreakdownTags(Data)
{
Width = width,
Culture = Culture,
ShowPercentages = ShowAsPercentages,
ShowTagValues = ShowTagValues,
});
}
return ((IRenderable)grid).Render(context, width);
}
}
}

View File

@ -0,0 +1,38 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// An item that's shown in a breakdown chart.
/// </summary>
public sealed class BreakdownChartItem : IBreakdownChartItem
{
/// <summary>
/// Gets the item label.
/// </summary>
public string Label { get; }
/// <summary>
/// Gets the item value.
/// </summary>
public double Value { get; }
/// <summary>
/// Gets the item color.
/// </summary>
public Color Color { get; }
/// <summary>
/// Initializes a new instance of the <see cref="BreakdownChartItem"/> class.
/// </summary>
/// <param name="label">The item label.</param>
/// <param name="value">The item value.</param>
/// <param name="color">The item color.</param>
public BreakdownChartItem(string label, double value, Color color)
{
Label = label ?? throw new ArgumentNullException(nameof(label));
Value = value;
Color = color;
}
}
}

View File

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class BreakdownTags : Renderable
{
private readonly List<IBreakdownChartItem> _data;
public int? Width { get; set; }
public CultureInfo? Culture { get; set; }
public bool ShowPercentages { get; set; }
public bool ShowTagValues { get; set; } = true;
public BreakdownTags(List<IBreakdownChartItem> data)
{
_data = data ?? throw new ArgumentNullException(nameof(data));
}
protected override Measurement Measure(RenderContext context, int maxWidth)
{
var width = Math.Min(Width ?? maxWidth, maxWidth);
return new Measurement(width, width);
}
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var culture = Culture ?? CultureInfo.InvariantCulture;
var panels = new List<Panel>();
foreach (var item in _data)
{
var panel = new Panel(GetTag(item, culture));
panel.Inline = true;
panel.Padding = new Padding(0, 0);
panel.NoBorder();
panels.Add(panel);
}
foreach (var segment in ((IRenderable)new Columns(panels).Padding(0, 0)).Render(context, maxWidth))
{
yield return segment;
}
}
private string GetTag(IBreakdownChartItem item, CultureInfo culture)
{
return string.Format(
culture, "[{0}]■[/] {1}",
item.Color.ToMarkup() ?? "default",
FormatValue(item, culture)).Trim();
}
private string FormatValue(IBreakdownChartItem item, CultureInfo culture)
{
if (ShowTagValues)
{
return string.Format(culture, "{0} [grey]{1}{2}[/]",
item.Label.EscapeMarkup(), item.Value,
ShowPercentages ? "%" : string.Empty);
}
return item.Label.EscapeMarkup();
}
}
}

View File

@ -0,0 +1,23 @@
namespace Spectre.Console
{
/// <summary>
/// Represents a breakdown chart item.
/// </summary>
public interface IBreakdownChartItem
{
/// <summary>
/// Gets the item label.
/// </summary>
string Label { get; }
/// <summary>
/// Gets the item value.
/// </summary>
double Value { get; }
/// <summary>
/// Gets the item color.
/// </summary>
Color Color { get; }
}
}

View File

@ -40,6 +40,11 @@ namespace Spectre.Console
/// </summary>
public PanelHeader? Header { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not the panel is inlined.
/// </summary>
internal bool Inline { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="Panel"/> class.
/// </summary>
@ -71,29 +76,41 @@ namespace Spectre.Console
/// <inheritdoc/>
protected override IEnumerable<Segment> Render(RenderContext context, int maxWidth)
{
var edgeWidth = EdgeWidth;
var border = BoxExtensions.GetSafeBorder(Border, (context.LegacyConsole || !context.Unicode) && UseSafeBorder);
var borderStyle = BorderStyle ?? Style.Plain;
var showBorder = true;
if (border is NoBoxBorder)
{
showBorder = false;
edgeWidth = 0;
}
var child = new Padder(_child, Padding);
var childWidth = maxWidth - EdgeWidth;
var childWidth = maxWidth - edgeWidth;
if (!Expand)
{
var measurement = ((IRenderable)child).Measure(context, maxWidth - EdgeWidth);
var measurement = ((IRenderable)child).Measure(context, maxWidth - edgeWidth);
childWidth = measurement.Max;
}
var panelWidth = childWidth + EdgeWidth;
var panelWidth = childWidth + edgeWidth;
panelWidth = Math.Min(panelWidth, maxWidth);
var result = new List<Segment>();
// Panel top
AddTopBorder(result, context, border, borderStyle, panelWidth);
if (showBorder)
{
// Panel top
AddTopBorder(result, context, border, borderStyle, panelWidth);
}
// Split the child segments into lines.
var childSegments = ((IRenderable)child).Render(context, childWidth);
foreach (var line in Segment.SplitLines(context, childSegments, childWidth))
foreach (var (_, _, last, line) in Segment.SplitLines(context, childSegments, childWidth).Enumerate())
{
if (line.Count == 1 && line[0].IsWhiteSpace)
{
@ -102,7 +119,10 @@ namespace Spectre.Console
continue;
}
result.Add(new Segment(border.GetPart(BoxBorderPart.Left), borderStyle));
if (showBorder)
{
result.Add(new Segment(border.GetPart(BoxBorderPart.Left), borderStyle));
}
var content = new List<Segment>();
content.AddRange(line);
@ -117,20 +137,45 @@ namespace Spectre.Console
result.AddRange(content);
result.Add(new Segment(border.GetPart(BoxBorderPart.Right), borderStyle));
if (showBorder)
{
result.Add(new Segment(border.GetPart(BoxBorderPart.Right), borderStyle));
}
// Don't emit a line break if this is the last
// line, we're not showing the border, and we're
// not rendering this inline.
var emitLinebreak = !(last && !showBorder && !Inline);
if (!emitLinebreak)
{
continue;
}
result.Add(Segment.LineBreak);
}
// Panel bottom
result.Add(new Segment(border.GetPart(BoxBorderPart.BottomLeft), borderStyle));
result.Add(new Segment(border.GetPart(BoxBorderPart.Bottom).Repeat(panelWidth - EdgeWidth), borderStyle));
result.Add(new Segment(border.GetPart(BoxBorderPart.BottomRight), borderStyle));
result.Add(Segment.LineBreak);
if (showBorder)
{
result.Add(new Segment(border.GetPart(BoxBorderPart.BottomLeft), borderStyle));
result.Add(new Segment(border.GetPart(BoxBorderPart.Bottom).Repeat(panelWidth - EdgeWidth), borderStyle));
result.Add(new Segment(border.GetPart(BoxBorderPart.BottomRight), borderStyle));
}
// TODO: Need a better name for this?
// If we're rendering this as part of an inline parent renderable,
// such as columns, we should not emit the last line break.
if (!Inline)
{
result.Add(Segment.LineBreak);
}
return result;
}
private void AddTopBorder(List<Segment> result, RenderContext context, BoxBorder border, Style borderStyle, int panelWidth)
private void AddTopBorder(
List<Segment> result, RenderContext context, BoxBorder border,
Style borderStyle, int panelWidth)
{
var rule = new Rule
{

View File

@ -5,7 +5,7 @@ using Spectre.Console.Rendering;
namespace Spectre.Console
{
internal sealed class ProgressBar : Renderable
internal sealed class ProgressBar : Renderable, IHasCulture
{
public double Value { get; set; }
public double MaxValue { get; set; } = 100;
@ -15,6 +15,7 @@ namespace Spectre.Console
public char UnicodeBar { get; set; } = '━';
public char AsciiBar { get; set; } = '-';
public bool ShowValue { get; set; }
public CultureInfo? Culture { get; set; }
public Style CompletedStyle { get; set; } = new Style(foreground: Color.Yellow);
public Style FinishedStyle { get; set; } = new Style(foreground: Color.Green);
@ -36,17 +37,26 @@ namespace Spectre.Console
var bars = Math.Max(0, (int)(width * (completed / MaxValue)));
var value = completed.ToString(CultureInfo.InvariantCulture);
var value = completed.ToString(Culture ?? CultureInfo.InvariantCulture);
if (ShowValue)
{
bars = bars - value.Length - 1;
bars = Math.Max(0, bars);
}
yield return new Segment(new string(token, bars), style);
if (ShowValue)
{
yield return new Segment(" " + value, style);
// TODO: Fix this at some point
if (bars == 0)
{
yield return new Segment(value, style);
}
else
{
yield return new Segment(" " + value, style);
}
}
if (bars < width)

View File

@ -163,7 +163,7 @@ namespace Spectre.Console
return Array.Empty<Segment>();
}
var paragraph = new Markup(header.Text.Capitalize(), header.Style ?? defaultStyle)
var paragraph = new Markup(header.Text.CapitalizeFirstLetter(), header.Style ?? defaultStyle)
.Alignment(Justify.Center)
.Overflow(Overflow.Ellipsis);