Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 166 additions & 7 deletions PanoramicData.ChartMagic.Test/LegendLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using PanoramicData.ChartMagic.Renderers;
using System.Xml.Linq;
using static PanoramicData.ChartMagic.Test.Support.ChartFixtures;
using static PanoramicData.ChartMagic.Test.Support.RenderedChart;
Expand All @@ -18,21 +19,179 @@ public void LegendLabels_DoNotOverlap()
{
var legend = GroupById(Render(ColumnChart(SeriesChartType.Column, 3)), "legend");

var labels = Elements(legend, "text").OrderBy(t => Number(t, "x")).ToList();
var labels = Elements(legend, "text").ToList();
labels.Should().HaveCount(3);

// Issue #35: the labels used to be spaced by a fraction of their intended distance and
// sat on top of one another. Each label needs at least its own width of room, and at
// the default font size "Series 1" is about eight characters wide.
var minimumSpacing = 8 * 20 * 0.5;
for (var i = 1; i < labels.Count; i++)
// the default font size "Series 1" is about eight characters wide. A row too narrow for
// all three wraps, so a pair may instead be a line apart.
const double FontSize = 20;
var minimumSpacing = 8 * FontSize * 0.5;
for (var i = 0; i < labels.Count; i++)
{
(Number(labels[i], "x") - Number(labels[i - 1], "x"))
.Should()
.BeGreaterThan(minimumSpacing, "adjacent legend labels must not overlap");
for (var j = i + 1; j < labels.Count; j++)
{
var apartAcross = Math.Abs(Number(labels[j], "x") - Number(labels[i], "x")) > minimumSpacing;
var apartDown = Math.Abs(Number(labels[j], "y") - Number(labels[i], "y")) >= FontSize;
(apartAcross || apartDown).Should().BeTrue("legend labels must not overlap");
}
}
}

[Fact]
public void RowLegend_TooNarrowForItsEntries_WrapsOntoAnotherRow()
{
// MS-26600: three entries in a narrow row legend ran off its edge, cutting "Memory" to "Memor"
// and dropping "Disk" altogether.
var specification = ColumnChart(SeriesChartType.Column, 3);
string[] names = ["CPU", "Memory", "Disk"];
for (var index = 0; index < names.Length; index++)
{
specification.SeriesList[index].LegendText = names[index];
}

specification.LegendStyle = LegendStyle.Row;
specification.LegendXPositionPercent = 80;
specification.LegendWidthPercent = 20;
specification.LegendHeightPercent = 100;
specification.LegendFontSize = 12;

var document = Render(specification, 720, 400);
var labels = Elements(GroupById(document, "legend"), "text").ToList();

labels.Select(label => label.Value).Should().Equal(names);
labels.Select(label => Number(label, "y")).Distinct().Should().HaveCountGreaterThan(1, "the entries continue on another row");
labels.Should().AllSatisfy(label =>
(Number(label, "x") + TextMeasure.Width(label.Value, Number(label, "font-size"))).Should().BeLessThanOrEqualTo(720 * 0.2, "every label stays inside the legend"));
}

[Fact]
public void StackedChart_ListsItsSeriesTopDown_InTheOrderTheStackReads()
{
// MS-26596: the Microsoft chart control lists a stack from its top. Plain columns keep their order.
var stacked = ColumnChart(SeriesChartType.StackedColumn, 3);
var plain = ColumnChart(SeriesChartType.Column, 3);
stacked.LegendStyle = plain.LegendStyle = LegendStyle.Column;

var plainOrder = LegendOrder(plain);
plainOrder.Should().HaveCount(3);
LegendOrder(stacked).Should().Equal(Enumerable.Reverse(plainOrder));
}

[Fact]
public void LegendItemOrder_OverridesTheStackedReversal()
{
var stacked = ColumnChart(SeriesChartType.StackedColumn, 3);
stacked.LegendStyle = LegendStyle.Column;
var chart = stacked.ToChart();
chart.Legends[0].ItemOrder = LegendItemOrder.SameAsSeriesOrder;

var legendTexts = Elements(GroupById(Render(chart), "legend"), "text")
.OrderBy(t => Number(t, "y"))
.Select(t => t.Value);

var plain = ColumnChart(SeriesChartType.Column, 3);
plain.LegendStyle = LegendStyle.Column;
legendTexts.Should().Equal(LegendOrder(plain));
}

[Fact]
public void LongLegendText_IsWrappedAtTheThreshold()
{
// MS-26626: LegendTextWrapThreshold, 25 characters by default as in the Microsoft chart control.
var specification = ColumnChart(SeriesChartType.Column, 1);
specification.SeriesList[0].LegendText = "Average processor utilisation";
specification.LegendStyle = LegendStyle.Column;

var lines = Elements(GroupById(Render(specification), "legend"), "text").Select(t => t.Value).ToList();

lines.Should().Equal("Average processor", "utilisation");
}

[Fact]
public void LegendLabelTooWideForTheImage_IsShortenedWithAnEllipsis_AfterUsingTheInset()
{
// MS-26593: a long label in the right-hand legend ran off the image. It takes the inset
// first, and is shortened only when it still would not fit.
var specification = ColumnChart(SeriesChartType.Column, 2);
specification.SeriesList[0].LegendText = "Manchester";
specification.SeriesList[1].LegendText = "Wolverhampton and Walsall";
specification.LegendStyle = LegendStyle.Column;
specification.LegendXPositionPercent = 80;
specification.LegendWidthPercent = 20;
specification.LegendFontSize = 12;

var document = Render(specification, 720, 400);
var labels = Elements(GroupById(document, "legend"), "text").ToList();

labels[0].Value.Should().Be("Manchester", "it fits once the inset is given up");
labels.Should().AllSatisfy(label =>
(576 + Number(label, "x") + TextMeasure.Width(label.Value, Number(label, "font-size"))).Should().BeLessThanOrEqualTo(720));
labels.Select(label => label.Value).Should().Contain(text => text.EndsWith("...", StringComparison.Ordinal));
}

[Fact]
public void TableLegend_TooShortForOneColumn_UsesSeveral()
{
// MS-26599: the Table style threw NotSupportedException.
var specification = ColumnChart(SeriesChartType.Column, 4);
specification.LegendStyle = LegendStyle.Table;
specification.LegendXPositionPercent = 0;
specification.LegendYPositionPercent = 85;
specification.LegendWidthPercent = 100;
specification.LegendHeightPercent = 15;
specification.LegendFontSize = 12;

var labels = Elements(GroupById(Render(specification, 720, 400), "legend"), "text").ToList();

labels.Should().HaveCount(4);
labels.Select(label => Number(label, "x")).Distinct().Should().HaveCountGreaterThan(1, "the entries are spread over columns");
}

[Fact]
public void ColumnLegend_TooShortForItsEntries_KeepsThemALineApart()
{
// MS-26677: a 10% legend drew three entries on top of one another.
var specification = ColumnChart(SeriesChartType.Column, 3);
specification.LegendStyle = LegendStyle.Column;
specification.LegendHeightPercent = 10;
specification.LegendWidthPercent = 20;
specification.LegendFontSize = 12;

var ys = Elements(GroupById(Render(specification, 720, 400), "legend"), "text")
.Select(label => Number(label, "y"))
.Order()
.ToList();

for (var index = 1; index < ys.Count; index++)
{
(ys[index] - ys[index - 1]).Should().BeGreaterThanOrEqualTo(12 * 0.9, "a line apart at least");
}
}

[Fact]
public void PieLegend_HonoursTheRowStyle()
{
// MS-26623: a pie legend was always one column whatever the style.
var specification = SingleSeries(SeriesChartType.Pie, Points(34, 26, 18, 13));
specification.LegendStyle = LegendStyle.Row;
specification.LegendXPositionPercent = 0;
specification.LegendYPositionPercent = 85;
specification.LegendWidthPercent = 100;
specification.LegendHeightPercent = 15;

var labels = Elements(GroupById(Render(specification, 720, 400), "legend"), "text").ToList();

labels.Select(label => Number(label, "y")).Distinct().Should().ContainSingle("one row holds every slice");
}

private static List<string> LegendOrder(ChartSpecification specification)
=> [.. Elements(GroupById(Render(specification), "legend"), "text")
.OrderBy(t => Number(t, "y"))
.Select(t => t.Value)];


[Fact]
public void LegendLabels_AreNotOutlinedInBlack()
{
Expand Down
14 changes: 10 additions & 4 deletions PanoramicData.ChartMagic.Test/PlotLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,14 @@ public void TranslucentFill_KeepsItsBorderOpaque()
specification.ChartBackgroundColor = Color.FromArgb(0x33, 0x77, 0x77, 0x77);
specification.ChartBorderColor = Color.Black;

var background = GroupById(Render(specification), "chartBackgroundArea");
var document = Render(specification);
var background = GroupById(document, "chartBackgroundArea");
var style = Elements(background, "rect")[0].Attribute("style")!.Value;

// The border is drawn last, on top of the legend, in an element of its own.
var border = document.Descendants().Single(e => e.Attribute("id")?.Value == "chartBorder");
var borderStyle = border.Attribute("style")!.Value.Split(';');

// Split into declarations rather than matching substrings: "fill-opacity" contains
// "opacity", so a substring check cannot tell the two apart.
var declarations = style.Split(';');
Expand All @@ -111,7 +116,8 @@ public void TranslucentFill_KeepsItsBorderOpaque()
declarations.Should().NotContain(
"opacity:0.20",
"issue #35: element opacity faded the border along with the fill");
declarations.Should().Contain("stroke:#000000");
borderStyle.Should().Contain("stroke:#000000");
borderStyle.Should().NotContain(declaration => declaration.Contains("opacity", StringComparison.Ordinal));
}

/// <summary>
Expand Down Expand Up @@ -175,7 +181,7 @@ public void EveryMarkerStyle_Renders(MarkerStyle markerStyle, string expectedEle

var document = Render(specification);

Elements(Defs(document), expectedElement).Should().HaveCount(1, "the marker is defined once and reused");
Elements(Defs(document), expectedElement).Where(e => e.Parent?.Name.LocalName != "clipPath").Should().HaveCount(1, "the marker is defined once and reused");
Elements(GroupById(document, "series0"), "use").Should().HaveCount(4, "one per point");
}

Expand All @@ -184,7 +190,7 @@ public void MarkerStyleNone_DefinesNoMarker()
{
var document = Render(SingleSeries(SeriesChartType.Line, Points(10, 24, 17, 31)));

Defs(document).Elements().Should().BeEmpty();
Defs(document).Elements().Where(e => e.Name.LocalName != "clipPath").Should().BeEmpty("the plot clip is the only definition without a marker");
Elements(GroupById(document, "series0"), "use").Should().BeEmpty();
}
}
Loading
Loading