diff --git a/PanoramicData.ChartMagic.Test/LegendLayoutTests.cs b/PanoramicData.ChartMagic.Test/LegendLayoutTests.cs index 55c7918..a3a0a33 100644 --- a/PanoramicData.ChartMagic.Test/LegendLayoutTests.cs +++ b/PanoramicData.ChartMagic.Test/LegendLayoutTests.cs @@ -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; @@ -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 LegendOrder(ChartSpecification specification) + => [.. Elements(GroupById(Render(specification), "legend"), "text") + .OrderBy(t => Number(t, "y")) + .Select(t => t.Value)]; + + [Fact] public void LegendLabels_AreNotOutlinedInBlack() { diff --git a/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs b/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs index 9e8f1a2..f3ccc21 100644 --- a/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs +++ b/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs @@ -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(';'); @@ -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)); } /// @@ -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"); } @@ -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(); } } diff --git a/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs b/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs new file mode 100644 index 0000000..ce235b9 --- /dev/null +++ b/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs @@ -0,0 +1,240 @@ +using PanoramicData.ChartMagic.Renderers; +using System.Drawing; +using System.Globalization; +using System.Xml.Linq; +using static PanoramicData.ChartMagic.Test.Support.ChartFixtures; +using static PanoramicData.ChartMagic.Test.Support.RenderedChart; + +namespace PanoramicData.ChartMagic.Test; + +/// +/// Settings the Magic Suite renderer comparison (MS-26667) found ignored, refused or drawn wrongly. +/// +public class RendererFidelityTests +{ + private static readonly string[] Cities = ["London", "Manchester", "Leeds"]; + + [Fact] + public void CurrencyFormat_UsesTheChartCulturesSymbol() + { + // MS-26683: with the invariant culture C2 shows the generic currency sign. + var chart = ColumnChart(SeriesChartType.Column, 1).ToChart(); + chart.ChartArea.YAxis.LabelFormat = "C2"; + chart.Culture = CultureInfo.GetCultureInfo("en-GB"); + + LabelTexts(Render(chart), "yAxis").Should().Contain("£30.00").And.NotContain(label => label.Contains('¤')); + } + + [Fact] + public void PercentFormat_UsesTheChartCulturesPattern() + { + // MS-26633: the invariant culture writes "3,500 %"; en-GB writes "3,500%". + var chart = ColumnChart(SeriesChartType.Column, 1).ToChart(); + chart.ChartArea.YAxis.LabelFormat = "P0"; + chart.Culture = CultureInfo.GetCultureInfo("en-GB"); + + LabelTexts(Render(chart), "yAxis").Should().Contain("3,000%"); + } + + [Fact] + public void DataLabels_UseTheChartCulture() + { + var specification = ColumnChart(SeriesChartType.Column, 1); + specification.SeriesList[0].LabelText = "#VAL"; + specification.SeriesList[0].Points = Points(1.5, 2.5, 3.5, 4.5); + var chart = specification.ToChart(); + chart.Culture = CultureInfo.GetCultureInfo("de-DE"); + + LabelTexts(Render(chart), "dataLabels").Should().Contain("1,5"); + } + + [Fact] + public void MajorGridInterval_IsIndependentOfTheLabelInterval() + { + // MS-26664: gridlines every 10 over labels every 5 drew a line at every label. + var chart = ColumnChart(SeriesChartType.Column, 1).ToChart(); + chart.ChartArea.YAxis.MajorGridEnabled = true; + chart.ChartArea.YAxis.MajorGridInterval = 10; + + var document = Render(chart); + + NumericLabels(document, "yAxis").Should().Contain(5, "the labels keep their own interval"); + HorizontalGridlineCount(document).Should().Be(4, "gridlines at 0, 10, 20 and 30 on a 0 to 35 axis"); + } + + [Fact] + public void MajorGridDashStyle_IsDrawn() + { + // MS-26606: a dashed gridline was refused, so the chart was not drawn at all. + var chart = ColumnChart(SeriesChartType.StackedColumn, 2).ToChart(); + chart.ChartArea.YAxis.MajorGridEnabled = true; + chart.ChartArea.YAxis.MajorGridDashStyle = ChartDashStyle.Dash; + + Gridlines(Render(chart)).Should().NotBeEmpty().And.AllSatisfy(line => + line.Attribute("stroke-dasharray").Should().NotBeNull()); + } + + [Fact] + public void MinorGridWidth_CanDifferFromTheMajor() + { + var chart = ColumnChart(SeriesChartType.Line, 1).ToChart(); + chart.ChartArea.YAxis.MajorGridEnabled = true; + chart.ChartArea.YAxis.MinorGridEnabled = true; + chart.ChartArea.YAxis.GridWidth = 3; + chart.ChartArea.YAxis.MinorGridWidth = 1; + + Gridlines(Render(chart)).Select(line => Number(line, "stroke-width")).Distinct().Should().BeEquivalentTo([1d, 3d]); + } + + [Fact] + public void Series_AreClippedToThePlot() + { + // MS-26641: data beyond an explicit maximum was drawn over the chart above the plot. + var chart = SingleSeries(SeriesChartType.Line, Points(10, 24, 17, 31)).ToChart(); + chart.ChartArea.YAxis.Min = 5; + chart.ChartArea.YAxis.Max = 25; + + var document = Render(chart); + var clip = Defs(document).Elements().Single(e => e.Name.LocalName == "clipPath"); + var plot = Elements(GroupById(document, "innerPlot"), "rect").First(); + + Number(clip.Elements().Single(), "height").Should().BeApproximately(Number(plot, "height"), 2.5); + GroupById(document, "series0").Attribute("clip-path")!.Value.Should().Be($"url(#{clip.Attribute("id")!.Value})"); + } + + [Fact] + public void IntervalStartsAtMinimum_CountsTicksFromTheMinimum() + { + // The Microsoft chart control labels a 5 to 25 axis at an interval of 4 as 5, 9 ... 25. + var chart = SingleSeries(SeriesChartType.Line, Points(10, 24, 17, 31)).ToChart(); + chart.ChartArea.YAxis.Min = 5; + chart.ChartArea.YAxis.Max = 25; + chart.ChartArea.YAxis.Interval = 4; + chart.ChartArea.YAxis.IntervalStartsAtMinimum = true; + + NumericLabels(Render(chart), "yAxis").Order().Should().Equal(5, 9, 13, 17, 21, 25); + } + + [Fact] + public void IntervalStartsAtMinimum_KeepsAFractionalMinimum() + { + var chart = SingleSeries(SeriesChartType.Line, Points(12, -8, 14, 26)).ToChart(); + chart.ChartArea.YAxis.Min = -15.5; + chart.ChartArea.YAxis.Max = 30.5; + chart.ChartArea.YAxis.Interval = 9; + chart.ChartArea.YAxis.IntervalStartsAtMinimum = true; + + NumericLabels(Render(chart), "yAxis").Order().Should().Equal(-15.5, -6.5, 2.5, 11.5, 20.5, 29.5); + } + + [Fact] + public void WithoutAMargin_TheCategoriesRunFromEndToEnd() + { + // MS-26628: the axis margin setting was refused. + var withMargin = SingleSeries(SeriesChartType.Line, Points(10, 24, 17, 31)).ToChart(); + var withoutMargin = SingleSeries(SeriesChartType.Line, Points(10, 24, 17, 31)).ToChart(); + withoutMargin.ChartArea.XAxis.IsMarginVisible = false; + + var marginVertices = PathVertexXValues(Elements(GroupById(Render(withMargin), "series0"), "path").First()); + var noMarginVertices = PathVertexXValues(Elements(GroupById(Render(withoutMargin), "series0"), "path").First()); + + marginVertices[0].Should().BeGreaterThan(0); + noMarginVertices[0].Should().Be(0, "the first category sits on the start of the axis"); + noMarginVertices[^1].Should().BeGreaterThan(marginVertices[^1], "and the last on its end"); + } + + [Fact] + public void YAxisTitle_InANarrowStrip_IsShrunkClearOfTheLabels() + { + // MS-26597: drawn at full size, the title lay over the tick labels. + var chart = ColumnChart(SeriesChartType.Line, 1).ToChart(); + chart.ChartArea.YAxis.Title = "Percent"; + chart.ChartArea.YAxis.LabelFormat = "0.0"; + chart.ChartArea.YAxis.FontSize = 16; + chart.ChartArea.YAxis.WidthPercent = 12; + + var document = Render(chart, 720, 400); + var title = Elements(GroupById(document, "yAxis"), "text").SingleOrDefault(t => t.Attribute("id")?.Value == "yAxisTitle"); + var labels = Elements(GroupById(document, "yAxis"), "text").Where(t => t.Attribute("id")?.Value != "yAxisTitle").ToList(); + + title.Should().NotBeNull(); + var titleRight = Number(title!, "x") + (Number(title!, "font-size") * 0.2); + var labelsLeft = labels.Min(label => Number(label, "x") - TextMeasure.Width(label.Value, Number(label, "font-size"))); + titleRight.Should().BeLessThan(labelsLeft, "the title is clear of the labels"); + Number(title!, "font-size").Should().BeLessThan(16, "it was shrunk to fit"); + } + + [Fact] + public void YAxisTitle_WithRoom_KeepsItsSize() + { + var chart = ColumnChart(SeriesChartType.Line, 1).ToChart(); + chart.ChartArea.YAxis.Title = "Percent"; + chart.ChartArea.YAxis.FontSize = 16; + chart.ChartArea.YAxis.WidthPercent = 20; + + var title = Elements(GroupById(Render(chart, 720, 400), "yAxis"), "text").Single(t => t.Attribute("id")?.Value == "yAxisTitle"); + + Number(title, "font-size").Should().Be(16); + } + + [Fact] + public void LabelBackColor_IsDrawnBehindEachDataLabel() + { + // MS-26614: a label background colour was refused. + var specification = ColumnChart(SeriesChartType.Column, 1); + specification.SeriesList[0].LabelText = "#VAL"; + var chart = specification.ToChart(); + chart.Series[0].LabelBackColor = Color.Yellow; + + var labels = GroupById(Render(chart), "dataLabels"); + + Elements(labels, "rect").Should().HaveCount(4).And.AllSatisfy(rect => rect.Attribute("fill")!.Value.Should().BeEquivalentTo("#FFFF00")); + Elements(labels, "text").Should().HaveCount(4); + } + + [Fact] + public void ChartBorder_IsDrawnLast_InsideTheImage_WithDashesScaledToItsWidth() + { + // MS-26605: a 4 pixel dashed border was covered by the legend box, lost half its width off + // the image edge, and read as solid because the dashes did not scale with the width. + var specification = ColumnChart(SeriesChartType.Column, 2); + specification.ChartBorderColor = Color.Red; + specification.ChartBorderWidth = 4; + specification.ChartBorderLineDashStyle = ChartDashStyle.Dash; + + var document = Render(specification, 720, 400); + var svg = document.Root!; + var border = svg.Elements().Last(); + + border.Attribute("id")!.Value.Should().Be("chartBorder", "it is drawn after everything else"); + Number(border, "x").Should().Be(2); + Number(border, "width").Should().Be(716); + var style = border.Attribute("style")!.Value.Split(';'); + style.Should().Contain("stroke-dasharray:12.00,4.00").And.Contain("stroke-linecap:butt"); + } + + [Fact] + public void LegendLabel_UsesTheInsetBeforeBeingShortened() + { + // MS-26593: "Manchester" fits a 144 pixel legend on the right of a 720 pixel image once + // the inset is given up, so it is not shortened. + var specification = SingleSeries(SeriesChartType.Pie, [.. Cities.Select((city, index) => new ChartPoint(city, index, 30 - (index * 5)))]); + specification.LegendStyle = LegendStyle.Column; + specification.LegendXPositionPercent = 80; + specification.LegendWidthPercent = 20; + specification.LegendFontSize = 16; + + LabelTexts(Render(specification, 720, 400), "legend").Should().Contain("Manchester"); + } + [Theory] + [InlineData(0, 30, 10, new double[] { 0, 10, 20, 30 })] + [InlineData(5, 25, 4, new double[] { 5, 9, 13, 17, 21, 25 })] + public void Linear_FromAnAnchor_CountsFromIt(double min, double max, double interval, double[] expected) + => TickGenerator.Linear(min, max, interval, 8, anchor: min).Should().Equal(expected); + + private static List Gridlines(XDocument document) + => Elements(GroupById(document, "gridlines"), "line"); + + private static int HorizontalGridlineCount(XDocument document) + => Gridlines(document).Count(line => Number(line, "y1") == Number(line, "y2")); +} diff --git a/PanoramicData.ChartMagic/Extensions/XmlElementExtensions.cs b/PanoramicData.ChartMagic/Extensions/XmlElementExtensions.cs index ad920a4..32799a5 100644 --- a/PanoramicData.ChartMagic/Extensions/XmlElementExtensions.cs +++ b/PanoramicData.ChartMagic/Extensions/XmlElementExtensions.cs @@ -1,4 +1,4 @@ -namespace PanoramicData.ChartMagic.Extensions; +namespace PanoramicData.ChartMagic.Extensions; internal static class XmlElementExtensions { @@ -39,20 +39,14 @@ private static void AddStrokeStyle(List style, ChartNamedElement element style.Add($"stroke-opacity:{(element.StrokeColor.A / 255f).ToString("F2", CultureInfo.InvariantCulture)}"); } - var dashArray = element.StrokeStyle switch - { - ChartDashStyle.Dash => "5,2", - ChartDashStyle.DashDot => "5,2,1,2", - ChartDashStyle.DashDotDot => "5,2,1,2,1,2", - ChartDashStyle.Dot => "5,2", - _ => null - }; + var dashArray = SvgCanvas.DashArrayFor(element.StrokeStyle, element.StrokeWidth); if (dashArray is not null) { style.Add($"stroke-dasharray:{dashArray}"); } - var lineCap = element.StrokeLineCapStyle switch + // Round caps grow every dash by the line width, which closes the gaps. + var lineCap = dashArray is not null ? "butt" : element.StrokeLineCapStyle switch { StrokeLineCapStyle.Square => "square", StrokeLineCapStyle.Round => "round", diff --git a/PanoramicData.ChartMagic/Models/AxisArea.cs b/PanoramicData.ChartMagic/Models/AxisArea.cs index e4f76b7..1e98425 100644 --- a/PanoramicData.ChartMagic/Models/AxisArea.cs +++ b/PanoramicData.ChartMagic/Models/AxisArea.cs @@ -80,6 +80,33 @@ public class AxisArea(IChartElement parent, string name) : ChartNamedElement(par /// public double GridWidth { get; set; } = 1; + /// + /// The width of minor gridlines, in pixels, where it differs from . + /// + public double? MinorGridWidth { get; set; } + + /// The dash pattern of major gridlines. + public ChartDashStyle MajorGridDashStyle { get; set; } + + /// The dash pattern of minor gridlines. + public ChartDashStyle MinorGridDashStyle { get; set; } + + /// + /// Whether a category axis leaves an interval of space before the first category and after the + /// last. Off, the first and last categories sit on the ends of the axis. + /// + public bool IsMarginVisible { get; set; } = true; + + /// + /// Whether ticks and gridlines are counted from rather than placed on + /// multiples of the interval. + /// + /// + /// The Microsoft chart control labels an axis with an explicit minimum from that minimum: 5, 9, + /// 13 for a minimum of 5 and an interval of 4, where multiples of 4 would give 8, 12, 16. + /// + public bool IntervalStartsAtMinimum { get; set; } + /// /// Whether the axis line, ticks and tick labels are drawn. The axis strip's background is /// drawn regardless. diff --git a/PanoramicData.ChartMagic/Models/Chart.cs b/PanoramicData.ChartMagic/Models/Chart.cs index f03934f..874eb44 100644 --- a/PanoramicData.ChartMagic/Models/Chart.cs +++ b/PanoramicData.ChartMagic/Models/Chart.cs @@ -28,6 +28,15 @@ public Chart() public AnnotationCollection Annotations { get; } + /// + /// The culture numbers are formatted in: axis labels and data labels. + /// + /// + /// Invariant by default. A currency format such as C2 shows the culture's own symbol, so with + /// the invariant culture it is the generic one. + /// + public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture; + // If there is no debug parameter public void SaveImage(Stream stream, ChartImageFormat chartImageFormat, int widthPixels, int heightPixels) => SaveImage(stream, chartImageFormat, widthPixels, heightPixels, false); diff --git a/PanoramicData.ChartMagic/Models/Legend.cs b/PanoramicData.ChartMagic/Models/Legend.cs index fb7ef8c..04d1671 100644 --- a/PanoramicData.ChartMagic/Models/Legend.cs +++ b/PanoramicData.ChartMagic/Models/Legend.cs @@ -1,6 +1,15 @@ -namespace PanoramicData.ChartMagic.Models; +namespace PanoramicData.ChartMagic.Models; public class Legend(IChartElement parent, string name) : ChartNamedElement(parent, name) { public LegendStyle Style { get; set; } + + /// The order the entries are listed in. + public LegendItemOrder ItemOrder { get; set; } + + /// + /// Entry text longer than this many characters is wrapped at a space. Zero turns wrapping off. + /// + /// 25 by default, as in the Microsoft chart control. + public int TextWrapThreshold { get; set; } = 25; } diff --git a/PanoramicData.ChartMagic/Models/LegendItemOrder.cs b/PanoramicData.ChartMagic/Models/LegendItemOrder.cs new file mode 100644 index 0000000..070b0cb --- /dev/null +++ b/PanoramicData.ChartMagic/Models/LegendItemOrder.cs @@ -0,0 +1,19 @@ +namespace PanoramicData.ChartMagic.Models; + +/// +/// The order a legend lists its entries in. +/// +public enum LegendItemOrder +{ + /// + /// Series order, reversed when the series are stacked, so the legend reads top to bottom in + /// the order the stack does. This is what the Microsoft chart control does by default. + /// + Auto, + + /// Always the order the series were added in. + SameAsSeriesOrder, + + /// Always the reverse of the order the series were added in. + ReversedSeriesOrder +} diff --git a/PanoramicData.ChartMagic/Models/Series.cs b/PanoramicData.ChartMagic/Models/Series.cs index 10ba9f9..e767270 100644 --- a/PanoramicData.ChartMagic/Models/Series.cs +++ b/PanoramicData.ChartMagic/Models/Series.cs @@ -9,6 +9,9 @@ public class Series(ChartElement parent, string name) : ChartNamedElement(parent public bool IsXValueIndexed { get; set; } public string? LabelText { get; set; } public string? LegendText { get; set; } + + /// The fill drawn behind each data label, or null for none. + public Color? LabelBackColor { get; set; } public MarkerStyle MarkerStyle { get; set; } = MarkerStyle.None; public Color? MarkerStrokeColor { get; set; } public Color? MarkerFillColor { get; set; } diff --git a/PanoramicData.ChartMagic/Renderers/AxisRenderer.cs b/PanoramicData.ChartMagic/Renderers/AxisRenderer.cs index d97a3b2..5119aef 100644 --- a/PanoramicData.ChartMagic/Renderers/AxisRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/AxisRenderer.cs @@ -84,7 +84,7 @@ private void PlotHorizontalGridlines(Chart chart, PlotGeometry geometry, AxisAre foreach (var value in MinorTicks(yAxis, geometry, isValueAxis: !geometry.IsHorizontalPlot)) { var y = geometry.YToPixels(value); - gridNode.AppendChild(_canvas.Line(0, y, geometry.Width, y, yAxis.MinorGridColor, yAxis.GridWidth)); + gridNode.AppendChild(_canvas.Line(0, y, geometry.Width, y, yAxis.MinorGridColor, yAxis.MinorGridWidth ?? yAxis.GridWidth, yAxis.MinorGridDashStyle)); } } @@ -93,13 +93,72 @@ private void PlotHorizontalGridlines(Chart chart, PlotGeometry geometry, AxisAre return; } - foreach (var value in YAxisTickValues(chart, geometry)) + foreach (var value in HorizontalMajorGridValues(chart, geometry)) { var y = geometry.IsHorizontalPlot ? geometry.CategoryToPixels(value) : geometry.YToPixels(value); - gridNode.AppendChild(_canvas.Line(0, y, geometry.Width, y, yAxis.MajorGridColor, yAxis.GridWidth)); + gridNode.AppendChild(_canvas.Line(0, y, geometry.Width, y, yAxis.MajorGridColor, yAxis.GridWidth, yAxis.MajorGridDashStyle)); + } + } + + /// + /// Where the horizontal major gridlines go: at the Y axis labels, unless the gridlines have an + /// interval of their own. + /// + /// + /// The gridline interval is independent of the label interval in the Microsoft chart control: + /// gridlines every 10 over labels every 5 draws four lines on a 0 to 35 axis, not eight. + /// + private static IReadOnlyList HorizontalMajorGridValues(Chart chart, PlotGeometry geometry) + { + var yAxis = chart.ChartArea.YAxis; + if (yAxis.MajorGridInterval is not > 0 || geometry.YIsLogarithmic) + { + return YAxisTickValues(chart, geometry); + } + + return geometry.IsHorizontalPlot + ? EveryNth(geometry.Categories, yAxis.MajorGridInterval.Value) + : TickGenerator.Linear(geometry.YDisplayStart, geometry.YDisplayEnd, yAxis.MajorGridInterval, yAxis.TargetTickCount, ValueAxisAnchor(chart, geometry)); + } + + /// + /// Where the vertical major gridlines go: at the X axis labels, unless the gridlines have an + /// interval of their own. + /// + private static IReadOnlyList VerticalMajorGridValues(Chart chart, PlotGeometry geometry) + { + var xAxis = chart.ChartArea.XAxis; + if (xAxis.MajorGridInterval is not > 0) + { + return XAxisTickValues(chart, geometry); + } + + if (geometry.IsHorizontalPlot) + { + return TickGenerator.Linear(geometry.YDisplayStart, geometry.YDisplayEnd, xAxis.MajorGridInterval, xAxis.TargetTickCount, ValueAxisAnchor(chart, geometry)); } + + return geometry.IsCategorical + ? EveryNth(geometry.Categories, xAxis.MajorGridInterval.Value) + : TickGenerator.Linear(geometry.XDisplayStart, geometry.XDisplayEnd, xAxis.MajorGridInterval, xAxis.TargetTickCount); + } + + private static IReadOnlyList EveryNth(IReadOnlyList categories, double interval) + { + var step = Math.Max(1, (int)Math.Round(interval)); + return [.. categories.Where((_, index) => index % step == 0)]; } + /// + /// The value the value axis counts its ticks from, or null for multiples of the interval. + /// + /// + /// The value axis is the Y axis area's whichever way the plot runs: a bar chart keeps its range + /// there too. + /// + private static double? ValueAxisAnchor(Chart chart, PlotGeometry geometry) + => chart.ChartArea.YAxis is { IntervalStartsAtMinimum: true, Min: not null } ? geometry.YDisplayStart : null; + /// /// The vertical gridlines, which mark the values on the X axis. /// @@ -109,7 +168,7 @@ private void PlotVerticalGridlines(Chart chart, PlotGeometry geometry, AxisArea { foreach (var x in MinorGridPositions(xAxis, geometry)) { - gridNode.AppendChild(_canvas.Line(x, 0, x, geometry.Height, xAxis.MinorGridColor, xAxis.GridWidth)); + gridNode.AppendChild(_canvas.Line(x, 0, x, geometry.Height, xAxis.MinorGridColor, xAxis.MinorGridWidth ?? xAxis.GridWidth, xAxis.MinorGridDashStyle)); } } @@ -118,10 +177,10 @@ private void PlotVerticalGridlines(Chart chart, PlotGeometry geometry, AxisArea return; } - foreach (var value in XAxisTickValues(chart, geometry)) + foreach (var value in VerticalMajorGridValues(chart, geometry)) { var x = XAxisPixels(geometry, value); - gridNode.AppendChild(_canvas.Line(x, 0, x, geometry.Height, xAxis.MajorGridColor, xAxis.GridWidth)); + gridNode.AppendChild(_canvas.Line(x, 0, x, geometry.Height, xAxis.MajorGridColor, xAxis.GridWidth, xAxis.MajorGridDashStyle)); } } @@ -171,8 +230,8 @@ private void DrawXAxis(Chart chart, PlotGeometry geometry, AxisArea xAxis, XmlEl xAxisNode.AppendChild(_canvas.Line(x, 0, x, tickLength, xAxis.LineColor, xAxis.LineWidth)); var label = geometry.IsHorizontalPlot - ? FormatAxisValue(value, xAxis) - : geometry.CategoryLabel(value) ?? FormatAxisValue(value, xAxis); + ? FormatAxisValue(value, xAxis, chart.Culture) + : geometry.CategoryLabel(value) ?? FormatAxisValue(value, xAxis, chart.Culture); xAxisNode.AppendChild( _canvas.Text( @@ -217,6 +276,7 @@ private void DrawYAxis(Chart chart, PlotGeometry geometry, AxisArea yAxis, XmlEl var tickLength = yAxis.TickLengthPixels; var labelX = axisWidth - tickLength - TickLabelGapPixels; var labelStyle = TextStyle.Unstroked(yAxis.FontWeight, yAxis.FontFamily, yAxis.FontSize, yAxis.FontColor); + var widestLabel = 0d; foreach (var value in YAxisTickValues(chart, geometry)) { @@ -225,8 +285,9 @@ private void DrawYAxis(Chart chart, PlotGeometry geometry, AxisArea yAxis, XmlEl _canvas.Line(axisWidth - tickLength, y, axisWidth, y, yAxis.LineColor, yAxis.LineWidth)); var label = geometry.IsHorizontalPlot - ? geometry.CategoryLabel(value) ?? FormatAxisValue(value, yAxis) - : FormatAxisValue(value, yAxis); + ? geometry.CategoryLabel(value) ?? FormatAxisValue(value, yAxis, chart.Culture) + : FormatAxisValue(value, yAxis, chart.Culture); + widestLabel = Math.Max(widestLabel, TextMeasure.Width(label, yAxis.FontSize, yAxis.FontWeight)); yAxisNode.AppendChild( _canvas.Text( @@ -240,23 +301,46 @@ private void DrawYAxis(Chart chart, PlotGeometry geometry, AxisArea yAxis, XmlEl yAxis.LabelAngle)); } - if (yAxis.Title is { Length: > 0 }) + if (yAxis.Title is { Length: > 0 } + && YAxisTitleFontSize(labelX - widestLabel, yAxis.FontSize) is { } titleFontSize) { // Rotated a quarter turn anticlockwise and centred on the axis, as a Y axis title - // conventionally reads. + // conventionally reads. Rotated about its baseline, so the glyphs lie to its left. yAxisNode.AppendChild( _canvas.Text( "yAxisTitle", - yAxis.FontSize * 0.9, + TitleEdgeGapPixels + (titleFontSize * TitleAscentFraction), geometry.Height / 2, yAxis.Title, HorizontalAlignment.Center, - VerticalAlignment.Top, - labelStyle with { FontWeight = FontWeight.Bold }, + VerticalAlignment.Bottom, + labelStyle with { FontWeight = FontWeight.Bold, FontSize = titleFontSize }, -90)); } } + private const double TitleEdgeGapPixels = 1; + + private const double TitleAscentFraction = 0.8; + + /// The smallest a Y axis title is shrunk to before it is left out. + private const double MinimumTitleFontSize = 8; + + /// + /// The size the Y axis title can be drawn at in the space left of the labels, or null if none. + /// + /// + /// Drawn at full size, a title in a narrow strip lay over the tick labels. The Microsoft chart + /// control leaves it out; a smaller title still says what the axis measures, so it is shrunk to + /// fit first and only left out when it would be too small to read. + /// + private static double? YAxisTitleFontSize(double labelsLeftEdge, double fontSize) + { + var available = labelsLeftEdge - (2 * TitleEdgeGapPixels) - TickLabelGapPixels; + var fitted = Math.Min(fontSize, available); + return fitted >= MinimumTitleFontSize ? fitted : null; + } + private static double XAxisPixels(PlotGeometry geometry, double value) => geometry.IsHorizontalPlot ? geometry.ValueToPixels(value) @@ -274,7 +358,8 @@ private static IReadOnlyList XAxisTickValues(Chart chart, PlotGeometry g geometry.YDisplayStart, geometry.YDisplayEnd, chart.ChartArea.XAxis.Interval, - chart.ChartArea.XAxis.TargetTickCount); + chart.ChartArea.XAxis.TargetTickCount, + ValueAxisAnchor(chart, geometry)); } if (geometry.IsCategorical) @@ -312,7 +397,8 @@ private static IReadOnlyList YAxisTickValues(Chart chart, PlotGeometry g // The interval the bounds were derived from, so the labels land on the bounds // rather than being chosen again from the adjusted range. chart.ChartArea.YAxis.Interval ?? geometry.ValueAxisInterval, - chart.ChartArea.YAxis.TargetTickCount); + chart.ChartArea.YAxis.TargetTickCount, + ValueAxisAnchor(chart, geometry)); } /// @@ -413,20 +499,20 @@ private static IReadOnlyList MinorTicks(AxisArea axis, PlotGeometry geom /// /// Formats an axis value, honouring an explicit format string and the short-label option. /// - private static string FormatAxisValue(double value, AxisArea axis) + private static string FormatAxisValue(double value, AxisArea axis, CultureInfo culture) { if (axis.LabelFormat is { Length: > 0 }) { - return value.ToString(axis.LabelFormat, CultureInfo.InvariantCulture); + return value.ToString(axis.LabelFormat, culture); } if (axis.UseShortLabels) { - return ShortAxisLabel(value); + return ShortAxisLabel(value, culture); } // Two decimal places at most, and none where the value does not need them. - return value.ToString("0.##", CultureInfo.InvariantCulture); + return value.ToString("0.##", culture); } /// @@ -439,24 +525,24 @@ private static string FormatAxisValue(double value, AxisArea axis) /// anything under a thousand alone, which is why the setting had no effect on a percentage /// axis. /// - private static string ShortAxisLabel(double value) + private static string ShortAxisLabel(double value, CultureInfo culture) { var absolute = Math.Abs(value); if (absolute >= 1_000_000_000) { - return FormattableString.Invariant($"{value / 1_000_000_000:0.0}G"); + return (value / 1_000_000_000).ToString("0.0", culture) + "G"; } if (absolute >= 1_000_000) { - return FormattableString.Invariant($"{value / 1_000_000:0.0}M"); + return (value / 1_000_000).ToString("0.0", culture) + "M"; } if (absolute >= 1_000) { - return FormattableString.Invariant($"{value / 1_000:0.0}K"); + return (value / 1_000).ToString("0.0", culture) + "K"; } - return value.ToString("0.0", CultureInfo.InvariantCulture); + return value.ToString("0.0", culture); } } diff --git a/PanoramicData.ChartMagic/Renderers/DataLabelText.cs b/PanoramicData.ChartMagic/Renderers/DataLabelText.cs index eddfdfe..5f97023 100644 --- a/PanoramicData.ChartMagic/Renderers/DataLabelText.cs +++ b/PanoramicData.ChartMagic/Renderers/DataLabelText.cs @@ -20,6 +20,7 @@ internal static class DataLabelText /// The name of the series the point belongs to. /// The point's share of the series total, 0 to 100. /// The series total. + /// The culture numbers are written in; invariant when null. /// /// The keyword set is deliberately small: these are what appears in practice. An unrecognised /// keyword is left in place rather than blanked, so that it shows up as itself on the chart @@ -34,21 +35,23 @@ internal static class DataLabelText double value, string? seriesName, double percentage, - double total) + double total, + CultureInfo? culture = null) { if (text is not { Length: > 0 }) { return null; } - var formattedValue = FormatNumber(value); + culture ??= CultureInfo.InvariantCulture; + var formattedValue = FormatNumber(value, culture); return text - .Replace("#VALX", point.XValueString ?? FormatNumber(point.XValue), StringComparison.OrdinalIgnoreCase) + .Replace("#VALX", point.XValueString ?? FormatNumber(point.XValue, culture), StringComparison.OrdinalIgnoreCase) .Replace("#VALY", formattedValue, StringComparison.OrdinalIgnoreCase) .Replace("#VAL", formattedValue, StringComparison.OrdinalIgnoreCase) - .Replace("#PERCENT", percentage.ToString("0.00", CultureInfo.InvariantCulture) + "%", StringComparison.OrdinalIgnoreCase) - .Replace("#TOTAL", FormatNumber(total), StringComparison.OrdinalIgnoreCase) + .Replace("#PERCENT", percentage.ToString("0.00", culture) + "%", StringComparison.OrdinalIgnoreCase) + .Replace("#TOTAL", FormatNumber(total, culture), StringComparison.OrdinalIgnoreCase) .Replace("#LEGENDTEXT", point.LegendText ?? string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("#SERIESNAME", seriesName ?? string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("#SER", seriesName ?? string.Empty, StringComparison.OrdinalIgnoreCase); @@ -57,5 +60,5 @@ internal static class DataLabelText /// /// A number as a label shows it: up to two decimal places, and none when it is whole. /// - internal static string FormatNumber(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + internal static string FormatNumber(double value, CultureInfo culture) => value.ToString("0.##", culture); } diff --git a/PanoramicData.ChartMagic/Renderers/EmbeddedTypefaceProvider.cs b/PanoramicData.ChartMagic/Renderers/EmbeddedTypefaceProvider.cs index 17b3bda..2af281e 100644 --- a/PanoramicData.ChartMagic/Renderers/EmbeddedTypefaceProvider.cs +++ b/PanoramicData.ChartMagic/Renderers/EmbeddedTypefaceProvider.cs @@ -56,6 +56,9 @@ internal sealed class EmbeddedTypefaceProvider : ITypefaceProvider /// private static readonly Lazy Typeface = new(Load, LazyThreadSafetyMode.ExecutionAndPublication); + /// The embedded typeface, or null if the resource is missing. + internal static SKTypeface? Default => Typeface.Value; + /// /// Returns the embedded typeface for every request. /// diff --git a/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs b/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs index a16be1a..0ef8d71 100644 --- a/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs @@ -1,4 +1,4 @@ -namespace PanoramicData.ChartMagic.Renderers; +namespace PanoramicData.ChartMagic.Renderers; /// /// Writes a chart out as SVG. @@ -37,13 +37,13 @@ internal void SaveImage(Stream stream, Chart chart) if (funnelSeries is not null) { // A funnel has no axes either, and like a pie its legend describes points, not series. - var segments = PieSliceBuilder.Build(funnelSeries); + var segments = PieSliceBuilder.Build(funnelSeries, chart.Culture); new FunnelRenderer(_canvas).Plot(funnelSeries, segments, innerPlotNode, geometry.Width, geometry.Height); legends.PlotPieLegend(chart, segments, chartBackgroundAreaNode); } else if (pieSeries is not null) { - var slices = PieSliceBuilder.Build(pieSeries); + var slices = PieSliceBuilder.Build(pieSeries, chart.Culture); new PieRenderer(_canvas).Plot(pieSeries, slices, innerPlotNode, geometry.Width, geometry.Height); legends.PlotPieLegend(chart, slices, chartBackgroundAreaNode); } @@ -63,6 +63,11 @@ internal void SaveImage(Stream stream, Chart chart) PlotAnnotations(chart, chartBackgroundAreaNode); + if (_canvas.Border(chart.ChartBackgroundArea) is { } border) + { + _canvas.Document.DocumentElement!.AppendChild(border); + } + // Issue #27: UTF-8, not UTF-16. // // Encoding.Unicode is UTF-16 LE. A UTF-16 SVG is valid and renders fine in a browser, @@ -124,7 +129,7 @@ private void Initialize( svg.AppendChild(defs); // Chart background area - chartBackgroundAreaNode = _canvas.PositionedGroup(chart.ChartBackgroundArea, "chartBackgroundArea"); + chartBackgroundAreaNode = _canvas.PositionedGroup(chart.ChartBackgroundArea, "chartBackgroundArea", drawStroke: false); svg.AppendChild(chartBackgroundAreaNode); // ChartArea background diff --git a/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs b/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs index a5da061..5fec767 100644 --- a/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; namespace PanoramicData.ChartMagic.Renderers; @@ -21,6 +21,26 @@ internal sealed class LegendRenderer(SvgCanvas canvas) /// private const double LegendInsetFraction = 0.12; + /// The space kept between legend text and the edge of the image. + private const double ImageEdgeGapPixels = 1; + + /// The height of one line of legend text, as a multiple of the font size. + private const double LineHeightFraction = 1.15; + + /// + /// One legend entry: a swatch and its text. + /// + /// The id the entry's text node is given. + /// The entry text, before any wrapping or shortening. + /// The swatch colour. + /// Whether the swatch is drawn as a bar, for a line series. + private sealed record Entry(string Id, string Text, Color Color, bool IsLine); + + /// + /// An entry laid out: where its swatch goes, and the lines of text beside it. + /// + private sealed record Placement(Entry Entry, double X, double CentreY, IReadOnlyList Lines); + /// /// Draws the legend: one swatch and label per series, inside the legend box. /// @@ -39,169 +59,329 @@ internal void PlotLegends(Chart chart, XmlElement chartBackgroundAreaNode) } var legend = chart.Legends[0]; - var legendXmlElement = _canvas.PositionedGroup(legend, "legend", chart.ChartBackgroundArea); - chartBackgroundAreaNode.AppendChild(legendXmlElement); - - var metrics = MetricsFor(legend); + var entries = chart.Series + .Select((series, index) => new Entry( + $"legendSeries{index}Text", + LegendTextFor(series), + SwatchColorFor(series), + IsLine(series.ChartType))) + .ToList(); - var seriesIndex = 0; - foreach (var series in chart.Series) + if (IsReversed(legend.ItemOrder, chart.Series)) { - PlotSeriesLegendEntry(chart, legend, metrics, series, seriesIndex, legendXmlElement); - seriesIndex++; + entries.Reverse(); } + + Plot(chart, legend, entries, chartBackgroundAreaNode); } /// - /// One legend entry: its swatch and its label. + /// The legend for a pie, which describes slices rather than series. /// - private void PlotSeriesLegendEntry( - Chart chart, - Legend legend, - LegendMetrics metrics, - Series series, - int seriesIndex, - XmlElement legendXmlElement) + internal void PlotPieLegend(Chart chart, List slices, XmlElement chartBackgroundAreaNode) { - var (swatchX, swatchY) = SwatchOrigin(chart, legend, metrics, seriesIndex); - - // A line series is represented by a bar rather than a block, so that the legend - // distinguishes a line from a filled area at a glance. - var isLine = series.ChartType - is SeriesChartType.Line - or SeriesChartType.FastLine - or SeriesChartType.Spline - or SeriesChartType.StepLine; - var swatchHeight = isLine ? Math.Max(2, Math.Round(metrics.SwatchHeight / 4, 2)) : metrics.SwatchHeight; - var swatchTop = isLine ? swatchY + ((metrics.SwatchHeight - swatchHeight) / 2) : swatchY; - - // A line series carries its identity in its stroke, a filled series in its fill. - var swatchColor = isLine - ? series.StrokeColor - : series.FillColor != Colors.Transparent ? series.FillColor : series.StrokeColor; - - var swatchNode = CreateSwatch(swatchX, swatchTop, metrics.SwatchWidth, swatchHeight, swatchColor); - if (swatchColor.A != 255) + if (chart.Legends.Count == 0 || slices.Count == 0) { - swatchNode.SetAttribute( - "fill-opacity", - (swatchColor.A / 255f).ToString("F2", CultureInfo.InvariantCulture)); + return; } - legendXmlElement.AppendChild(swatchNode); + var entries = slices + .Select((slice, index) => new Entry( + FormattableString.Invariant($"legendSlice{index}Text"), + slice.LegendText, + slice.Color, + IsLine: false)) + .ToList(); - legendXmlElement.AppendChild( - _canvas.Text( - $"legendSeries{seriesIndex}Text", - swatchX + metrics.SwatchWidth + (metrics.Padding / 2), - swatchY + (metrics.SwatchHeight / 2), - LegendTextFor(series), - HorizontalAlignment.Left, - VerticalAlignment.Middle, - LabelStyleFor(legend))); + Plot(chart, chart.Legends[0], entries, chartBackgroundAreaNode); } /// - /// Where a series swatch sits within the legend, which is what the legend style decides. + /// Whether the entries are listed in reverse, which for a stacked chart is the order the stack + /// reads in from the top. /// - private static (double X, double Y) SwatchOrigin(Chart chart, Legend legend, LegendMetrics metrics, int seriesIndex) - => legend.Style switch + /// + /// Measured against the Microsoft chart control: a stacked column chart of CPU, Memory and Disk + /// lists Disk, Memory, CPU, and the same series as plain columns list CPU, Memory, Disk. + /// + private static bool IsReversed(LegendItemOrder itemOrder, SeriesCollection series) => itemOrder switch + { + LegendItemOrder.ReversedSeriesOrder => true, + LegendItemOrder.SameAsSeriesOrder => false, + _ => series.Any(s => PlotGeometry.IsStacked(s.ChartType)) + }; + + private void Plot(Chart chart, Legend legend, List entries, XmlElement chartBackgroundAreaNode) + { + var legendNode = _canvas.PositionedGroup(legend, "legend", chart.ChartBackgroundArea); + chartBackgroundAreaNode.AppendChild(legendNode); + + var metrics = MetricsFor(legend); + var bounds = BoundsFor(legend); + + var placements = legend.Style switch { - LegendStyle.Row => RowSwatchOrigin(chart, metrics, seriesIndex), - LegendStyle.Column => ColumnSwatchOrigin(metrics, seriesIndex, chart.Series.Count), - _ => throw new NotSupportedException($"Legend style {legend.Style} is not supported.") + LegendStyle.Row => LayOutRows(legend, metrics, bounds, entries), + LegendStyle.Table => LayOutTable(legend, metrics, bounds, entries), + _ => LayOutColumn(legend, metrics, bounds, entries) }; + var labelStyle = LabelStyleFor(legend); + foreach (var placement in placements) + { + AppendEntry(legendNode, metrics, labelStyle, placement); + } + } + /// - /// Where a swatch sits in a single-row legend. + /// Where the legend sits on the image, in pixels. + /// + private (double Left, double Top, double Width, double Height) BoundsFor(Legend legend) + => ( + _canvas.WidthPixels * legend.GetCanvasXLocationPercent() / 100, + _canvas.HeightPixels * (100 - legend.GetCanvasYLocationPercent() - legend.GetCanvasHeightPercent()) / 100, + _canvas.WidthPixels * legend.GetCanvasWidthPercent() / 100, + _canvas.HeightPixels * legend.GetCanvasHeightPercent() / 100); + + /// + /// One column of entries, spread down the legend. /// /// - /// Entries packed one after another and the row centred, rather than each given an equal share - /// of the width. - /// - /// Spreading them looks tidy on paper and is wrong twice over. It does not match the reference - /// render, which packs them and centres the result; and once the swatch became a rectangle - /// rather than a small square, a slot sized without reference to its contents put the next - /// swatch on top of the previous label. + /// + /// One row per series, spread down the legend rather than packed together, and left-aligned at + /// an inset proportional to the legend width. Both measured against the renderer this matches, + /// which gives each entry an equal share of the legend height: on a 400-pixel legend it spaced + /// three entries 129 apart and two 193 apart, which is the height less one swatch, divided by + /// the count. + /// + /// + /// A legend too short for its entries keeps them a line apart rather than drawing them over one + /// another, and moves them only as far as it must to keep them on the image. A label too wide + /// takes the inset first and is shortened with an ellipsis only when it would leave the image. + /// /// - private static (double X, double Y) RowSwatchOrigin(Chart chart, LegendMetrics metrics, int seriesIndex) + private List LayOutColumn( + Legend legend, + LegendMetrics metrics, + (double Left, double Top, double Width, double Height) bounds, + List entries) { - var entryWidths = chart.Series - .Select(series => metrics.SwatchWidth - + (metrics.Padding / 2) - + EstimateTextWidth(LegendTextFor(series), metrics.FontSize)) - .ToList(); + var lines = entries.ConvertAll(entry => TextMeasure.Wrap(entry.Text, legend.TextWrapThreshold)); + var widestText = lines.SelectMany(l => l).Select(line => TextMeasure.Width(line, metrics.FontSize, legend.FontWeight)).DefaultIfEmpty(0).Max(); + var inset = InsetFor(metrics, Math.Min(metrics.Width, _canvas.WidthPixels - ImageEdgeGapPixels - bounds.Left), widestText); + var maximumTextWidth = MaximumTextWidth(bounds, inset, metrics); - var gap = metrics.Padding * 2; - var rowWidth = entryWidths.Sum() + (gap * (entryWidths.Count - 1)); - - var x = Math.Round( - Math.Max(metrics.Padding, (metrics.Width - rowWidth) / 2) - + entryWidths.Take(seriesIndex).Sum() - + (gap * seriesIndex), - 2); - var y = Math.Round((metrics.Height - metrics.SwatchHeight) / 2, 2); - return (x, y); + var centres = SpreadDown(metrics, bounds, lines); + + return [.. entries.Select((entry, index) => new Placement( + entry, + inset, + centres[index], + Shorten(lines[index], maximumTextWidth, metrics.FontSize, legend.FontWeight)))]; + } + + /// + /// The inset an entry starts at: the measured fraction of the legend, less whatever a long + /// label needs to stay inside it. + /// + /// The legend's measurements. + /// How much of the legend's width is on the image. + /// The widest line of entry text. + private static double InsetFor(LegendMetrics metrics, double usableWidth, double widestText) + { + var standard = Math.Round(metrics.Width * LegendInsetFraction, 2); + var room = usableWidth - metrics.SwatchWidth - (metrics.Padding / 2) - widestText; + // Floored, so rounding never takes back the room just measured. + return Math.Floor(Math.Clamp(room, Math.Min(metrics.Padding / 2, standard), standard) * 100) / 100; + } + + /// + /// How wide a label starting at this inset can be before it leaves the image. + /// + private double MaximumTextWidth( + (double Left, double Top, double Width, double Height) bounds, + double inset, + LegendMetrics metrics) + => _canvas.WidthPixels - ImageEdgeGapPixels - (bounds.Left + inset + metrics.SwatchWidth + (metrics.Padding / 2)); + + /// + /// The vertical centre of each entry in one column. + /// + private List SpreadDown( + LegendMetrics metrics, + (double Left, double Top, double Width, double Height) bounds, + List> lines) + { + var count = lines.Count; + var spacing = (metrics.Height - metrics.SwatchHeight) / Math.Max(count, 1); + var tallest = lines.Select(l => EntryHeight(metrics, l.Count)).DefaultIfEmpty(metrics.SwatchHeight).Max(); + var minimumSpacing = tallest + (metrics.Padding / 2); + if (spacing < minimumSpacing) + { + spacing = minimumSpacing; + } + + var blockHeight = (spacing * (count - 1)) + tallest; + var blockTop = (metrics.Height - blockHeight) / 2; + + // Kept on the image, in the legend's own coordinates. + blockTop = Math.Clamp(blockTop, -bounds.Top, Math.Max(-bounds.Top, _canvas.HeightPixels - bounds.Top - blockHeight)); + + return [.. Enumerable.Range(0, count).Select(index => Math.Round(blockTop + (tallest / 2) + (index * spacing), 2))]; } /// - /// Where a swatch sits in a single-column legend. + /// Rows of entries, packed and centred, wrapping onto another row when the legend is too + /// narrow for them all. /// /// - /// One row per series, spread down the legend rather than packed together, and left-aligned at - /// an inset proportional to the legend width. - /// - /// Both measured against the renderer this matches, which gives each entry an equal share of - /// the legend height: on a 400-pixel legend it spaced three entries 129 apart and two 193 - /// apart, which is the height less one swatch, divided by the count. Packing them at 1.6 line - /// heights put all three within 50 pixels of the middle and left most of the legend empty. + /// Entries packed one after another and the row centred, rather than each given an equal share + /// of the width: that is what the reference render does, and a slot sized without reference to + /// its contents put the next swatch on top of the previous label. Where one row cannot hold + /// them the entries continue on the next, rather than running off the legend or being dropped: + /// the Microsoft chart control replaced them with "...", which leaves a reader no way to tell + /// which series is which. /// - private static (double X, double Y) ColumnSwatchOrigin(LegendMetrics metrics, int index, int count) - => ( - Math.Round(metrics.Width * LegendInsetFraction, 2), - Math.Round( - RowCentre(metrics.Height, index, count, metrics.SwatchHeight) - (metrics.SwatchHeight / 2), - 2)); + private List LayOutRows( + Legend legend, + LegendMetrics metrics, + (double Left, double Top, double Width, double Height) bounds, + List entries) + { + var gap = metrics.Padding * 2; + var available = Math.Max(metrics.Width - (2 * metrics.Padding), metrics.SwatchWidth); + var maximumTextWidth = available - metrics.SwatchWidth - (metrics.Padding / 2); + + var sized = entries.ConvertAll(entry => + { + var lines = Shorten(TextMeasure.Wrap(entry.Text, legend.TextWrapThreshold), maximumTextWidth, metrics.FontSize, legend.FontWeight); + var textWidth = lines.Select(line => TextMeasure.Width(line, metrics.FontSize, legend.FontWeight)).DefaultIfEmpty(0).Max(); + return (Entry: entry, Lines: lines, Width: metrics.SwatchWidth + (metrics.Padding / 2) + textWidth); + }); + + var rows = new List Lines, double Width)>> { new() }; + var rowWidth = 0d; + foreach (var item in sized) + { + var needed = rows[^1].Count == 0 ? item.Width : rowWidth + gap + item.Width; + if (rows[^1].Count > 0 && needed > available) + { + rows.Add([]); + needed = item.Width; + } + + rows[^1].Add(item); + rowWidth = needed; + } + + var rowHeights = rows.ConvertAll(row => row.Max(item => EntryHeight(metrics, item.Lines.Count))); + var rowGap = metrics.Padding / 2; + var blockHeight = rowHeights.Sum() + (rowGap * (rows.Count - 1)); + var top = Math.Clamp( + (metrics.Height - blockHeight) / 2, + -bounds.Top, + Math.Max(-bounds.Top, _canvas.HeightPixels - bounds.Top - blockHeight)); + + var placements = new List(); + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + var width = row.Sum(item => item.Width) + (gap * (row.Count - 1)); + var x = Math.Max(metrics.Padding, (metrics.Width - width) / 2); + var centreY = Math.Round(top + (rowHeights[rowIndex] / 2), 2); + + foreach (var item in row) + { + placements.Add(new Placement(item.Entry, Math.Round(x, 2), centreY, item.Lines)); + x += item.Width + gap; + } + + top += rowHeights[rowIndex] + rowGap; + } + + return placements; + } /// - /// The legend for a pie, which describes slices rather than series. + /// Several columns of entries, as many as the legend height needs. /// /// - /// A pie legend is a list: one row per slice whatever the legend style, because slices are - /// named and there are usually more of them than a single row would fit. The rows share the - /// legend height the same way a series legend does. + /// The Microsoft chart control's Table style: entries fill a column top to bottom and continue + /// in the next, so a short wide legend lists them side by side. While one column holds them all + /// it is laid out exactly as the Column style is. /// - internal void PlotPieLegend(Chart chart, List slices, XmlElement chartBackgroundAreaNode) + private List LayOutTable( + Legend legend, + LegendMetrics metrics, + (double Left, double Top, double Width, double Height) bounds, + List entries) { - if (chart.Legends.Count == 0 || slices.Count == 0) + var lines = entries.ConvertAll(entry => TextMeasure.Wrap(entry.Text, legend.TextWrapThreshold)); + var tallest = lines.Select(l => EntryHeight(metrics, l.Count)).DefaultIfEmpty(metrics.SwatchHeight).Max(); + var perColumn = Math.Max(1, (int)Math.Floor((metrics.Height + (metrics.Padding / 2)) / (tallest + (metrics.Padding / 2)))); + if (perColumn >= entries.Count) { - return; + return LayOutColumn(legend, metrics, bounds, entries); } - var legend = chart.Legends[0]; - var legendXmlElement = _canvas.PositionedGroup(legend, "legend", chart.ChartBackgroundArea); - chartBackgroundAreaNode.AppendChild(legendXmlElement); - - var metrics = MetricsFor(legend); - var inset = Math.Round(metrics.Width * LegendInsetFraction, 2); - var labelStyle = LabelStyleFor(legend); + var columns = (int)Math.Ceiling(entries.Count / (double)perColumn); + var columnWidth = metrics.Width / columns; + var maximumTextWidth = columnWidth - metrics.Padding - metrics.SwatchWidth - (metrics.Padding / 2); - for (var index = 0; index < slices.Count; index++) + var placements = new List(); + for (var column = 0; column < columns; column++) { - var slice = slices[index]; - var swatchY = Math.Round( - RowCentre(metrics.Height, index, slices.Count, metrics.SwatchHeight) - (metrics.SwatchHeight / 2), - 2); + var inColumn = Enumerable.Range(column * perColumn, Math.Min(perColumn, entries.Count - (column * perColumn))).ToList(); + var centres = SpreadDown(metrics, bounds, [.. inColumn.Select(index => lines[index])]); + for (var row = 0; row < inColumn.Count; row++) + { + var index = inColumn[row]; + placements.Add(new Placement( + entries[index], + Math.Round((column * columnWidth) + (metrics.Padding / 2), 2), + centres[row], + Shorten(lines[index], maximumTextWidth, metrics.FontSize, legend.FontWeight))); + } + } - legendXmlElement.AppendChild( - CreateSwatch(inset, swatchY, metrics.SwatchWidth, metrics.SwatchHeight, slice.Color)); + return placements; + } + + private static double EntryHeight(LegendMetrics metrics, int lineCount) + => Math.Max(metrics.SwatchHeight, lineCount * metrics.FontSize * LineHeightFraction); + + private static List Shorten(IReadOnlyList lines, double maximumWidth, double fontSize, FontWeight fontWeight) + => [.. lines.Select(line => TextMeasure.Fit(line, Math.Max(maximumWidth, 0), fontSize, fontWeight))]; + + /// + /// Draws one entry: its swatch and its text. + /// + /// + /// A line series is represented by a bar rather than a block, so that the legend distinguishes + /// a line from a filled area at a glance. + /// + private void AppendEntry(XmlElement legendNode, LegendMetrics metrics, TextStyle labelStyle, Placement placement) + { + var swatchHeight = placement.Entry.IsLine ? Math.Max(2, Math.Round(metrics.SwatchHeight / 4, 2)) : metrics.SwatchHeight; + var swatchNode = CreateSwatch( + placement.X, + Math.Round(placement.CentreY - (swatchHeight / 2), 2), + metrics.SwatchWidth, + swatchHeight, + placement.Entry.Color); + legendNode.AppendChild(swatchNode); - legendXmlElement.AppendChild( + var lineHeight = metrics.FontSize * LineHeightFraction; + var textX = placement.X + metrics.SwatchWidth + (metrics.Padding / 2); + for (var line = 0; line < placement.Lines.Count; line++) + { + var y = placement.CentreY + ((line - ((placement.Lines.Count - 1) / 2.0)) * lineHeight); + legendNode.AppendChild( _canvas.Text( - FormattableString.Invariant($"legendSlice{index}Text"), - inset + metrics.SwatchWidth + (metrics.Padding / 2), - swatchY + (metrics.SwatchHeight / 2), - slice.LegendText, + line == 0 ? placement.Entry.Id : FormattableString.Invariant($"{placement.Entry.Id}{line}"), + textX, + y, + placement.Lines[line], HorizontalAlignment.Left, VerticalAlignment.Middle, labelStyle)); @@ -219,9 +399,31 @@ private XmlElement CreateSwatch(double x, double y, double width, double height, swatchNode.SetAttribute("width", width.ToString(CultureInfo.InvariantCulture)); swatchNode.SetAttribute("height", height.ToString(CultureInfo.InvariantCulture)); swatchNode.SetAttribute("fill", color.ToHex()); + if (color.A != 255) + { + swatchNode.SetAttribute( + "fill-opacity", + (color.A / 255f).ToString("F2", CultureInfo.InvariantCulture)); + } + return swatchNode; } + /// + /// The colour a series' swatch is drawn in: a line carries its identity in its stroke, a filled + /// series in its fill. + /// + private static Color SwatchColorFor(Series series) + => IsLine(series.ChartType) + ? series.StrokeColor + : series.FillColor != Colors.Transparent ? series.FillColor : series.StrokeColor; + + private static bool IsLine(SeriesChartType chartType) => chartType + is SeriesChartType.Line + or SeriesChartType.FastLine + or SeriesChartType.Spline + or SeriesChartType.StepLine; + /// /// The pixel measurements this legend is laid out in. /// @@ -242,31 +444,4 @@ private static TextStyle LabelStyleFor(Legend legend) /// private static string LegendTextFor(Series series) => series.LegendText is { Length: > 0 } ? series.LegendText : series.Name; - - /// - /// How wide a piece of text will be, near enough to lay a row out with. - /// - /// - /// An estimate from the character count, because there is no text measurement here - the SVG - /// is written out rather than drawn, so nothing in this library knows a font's metrics. It is - /// good enough to stop entries colliding, which is what it is for; it is not good enough to - /// match a reference render to the pixel, and legend width fidelity is limited by that. - /// - private static double EstimateTextWidth(string text, double fontSize) - => text.Length * fontSize * 0.55; - - /// - /// The centre of one legend row, for entries sharing the legend height equally. - /// - /// - /// The rows are spread rather than packed: the reference render spaced three entries 129 apart - /// and two 193 apart on a 400-pixel legend, which is the height less one swatch divided by the - /// count, with the block centred. - /// - private static double RowCentre(double legendHeight, int index, int count, double swatchHeight) - { - var spacing = (legendHeight - swatchHeight) / Math.Max(count, 1); - return (legendHeight / 2) + ((index - ((count - 1) / 2.0)) * spacing); - } - } diff --git a/PanoramicData.ChartMagic/Renderers/PieRenderer.cs b/PanoramicData.ChartMagic/Renderers/PieRenderer.cs index 9150f00..24289e6 100644 --- a/PanoramicData.ChartMagic/Renderers/PieRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/PieRenderer.cs @@ -130,6 +130,12 @@ private void PlotPieLabels( ? OutsidePieLabelPosition(centreX, centreY, radius, slice) : InsidePieLabelPosition(centreX, centreY, radius, innerRadius, slice); + if (series.LabelBackColor is { A: > 0 } background) + { + pieNode.AppendChild( + _canvas.TextBackground(at.X, at.Y, slice.Label, alignment, VerticalAlignment.Middle, labelStyle, background)); + } + pieNode.AppendChild( _canvas.Text( FormattableString.Invariant($"pieLabel{slice.StartAngleDegrees:F2}"), diff --git a/PanoramicData.ChartMagic/Renderers/PieSlice.cs b/PanoramicData.ChartMagic/Renderers/PieSlice.cs index 5e6c085..ccd2335 100644 --- a/PanoramicData.ChartMagic/Renderers/PieSlice.cs +++ b/PanoramicData.ChartMagic/Renderers/PieSlice.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; namespace PanoramicData.ChartMagic.Renderers; @@ -59,7 +59,7 @@ internal static class PieSliceBuilder /// Builds the slices for a series, applying the collected-slice threshold and turning values /// into angles. /// - internal static List Build(Series series) + internal static List Build(Series series, CultureInfo? culture = null) { var values = PositiveValues(series); var total = values.Sum(v => v.Value); @@ -80,7 +80,7 @@ internal static List Build(Series series) var sweep = percentage / 100 * 360; slices.Add(new PieSlice( - Label: LabelFor(series, entry.Point, entry.Value, percentage, total), + Label: LabelFor(series, entry.Point, entry.Value, percentage, total, culture ?? CultureInfo.InvariantCulture), LegendText: LegendTextFor(entry.Point, entry.Value), Value: entry.Value, Percentage: percentage, @@ -164,15 +164,15 @@ private static PieSlice CollectedSlice(Series series, double collectedValue, dou SweepAngleDegrees: percentage / 100 * 360); } - private static string LabelFor(Series series, ChartPoint point, double value, double percentage, double total) + private static string LabelFor(Series series, ChartPoint point, double value, double percentage, double total, CultureInfo culture) => series.PieLabelStyle == Models.PieLabelStyle.Disabled ? string.Empty - : DataLabelText.Substitute(series.LabelText, point, value, series.Name, percentage, total) + : DataLabelText.Substitute(series.LabelText, point, value, series.Name, percentage, total, culture) // The category name, not the value. Measured against DocMagic: with no label text // set, the Microsoft chart control labels a pie slice with its X value, so a pie of // cities reads London, Manchester rather than 34, 26. ?? point.XValueString - ?? value.ToString("0.##", CultureInfo.InvariantCulture); + ?? value.ToString("0.##", culture); private static string LegendTextFor(ChartPoint point, double value) => point.LegendText diff --git a/PanoramicData.ChartMagic/Renderers/PlotGeometry.cs b/PanoramicData.ChartMagic/Renderers/PlotGeometry.cs index 759e9b5..0ce3f60 100644 --- a/PanoramicData.ChartMagic/Renderers/PlotGeometry.cs +++ b/PanoramicData.ChartMagic/Renderers/PlotGeometry.cs @@ -1,4 +1,4 @@ -namespace PanoramicData.ChartMagic.Renderers; +namespace PanoramicData.ChartMagic.Renderers; /// /// Maps data values onto pixel positions inside the inner plot, and answers the questions the @@ -37,6 +37,7 @@ internal PlotGeometry(Chart chart, AxisHandlerResult axisHandlerResult, double w IsHorizontalPlot = bandedSeries.Count > 0 && bandedSeries.TrueForAll(series => IsHorizontal(series.ChartType)); YIsLogarithmic = chart.ChartArea.YAxis.IsLogarithmic; + IsMarginVisible = chart.ChartArea.XAxis.IsMarginVisible; PopulateCategories(chart); IsPercentStackedPlot = chart.Series.Any(series => IsPercentStacked(series.ChartType)); @@ -158,6 +159,12 @@ private static (double Minimum, double Maximum, double Start, double Range) GetL internal bool IsCategorical { get; } + /// + /// Whether the category axis leaves an interval free at each end. Read from the X axis area for + /// bar charts too, since it is a property of the category axis wherever that is drawn. + /// + internal bool IsMarginVisible { get; } + internal bool YIsLogarithmic { get; } /// Whether the category axis runs vertically, as it does for bar charts. @@ -218,7 +225,17 @@ internal double ToPercentOfCategory(double xValue, double value) /// either chart type. Column groups were therefore drawn up to 27 pixels away from where they /// belonged, and proportionally wider with it. /// - private int CategoryIntervalCount => _categories.Count + 1; + /// + /// Without the margin, the first and last categories sit on the ends of the axis, so there are + /// one fewer intervals than categories. A single category is then centred. + /// + private int CategoryIntervalCount => IsMarginVisible ? _categories.Count + 1 : _categories.Count > 1 ? _categories.Count - 1 : 2; + + /// + /// How many intervals along the axis the category at this index is. + /// + private double CategoryOffset(int index) + => IsMarginVisible ? index + 1 : _categories.Count == 1 ? 1 : index; /// /// The width of one category interval, or zero when the axis is not categorical. @@ -290,7 +307,7 @@ internal double CategoryToPixels(double xValue) index = 0; } - var distanceAlongAxis = (index + 1) * CategoryBandExtent; + var distanceAlongAxis = CategoryOffset(index) * CategoryBandExtent; // Distances are measured down the plot, so an axis that runs upwards is that distance // taken from the far edge. @@ -334,7 +351,7 @@ internal double XToPixels(double xValue) index = 0; } - return Math.Round((index + 1) * BandWidth, 2); + return Math.Round(CategoryOffset(index) * BandWidth, 2); } return IsNearlyZero(_xDisplayRange) diff --git a/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs b/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs index bd8be6f..c6cb5ee 100644 --- a/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs @@ -16,11 +16,14 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm var stackLines = _canvas.Group("stackLines"); var dataLabels = _canvas.Group("dataLabels"); var bands = BandLayout.For(chart); + var clip = PlotClip(geometry, defs); + stackLines.SetAttribute("clip-path", clip); var seriesIndex = -1; foreach (var series in chart.Series) { var seriesNode = _canvas.Group($"series{++seriesIndex}"); + seriesNode.SetAttribute("clip-path", clip); List labelAnchors; // Add markers to defs if required @@ -43,7 +46,7 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm } innerPlotNode.AppendChild(seriesNode); - AppendDataLabels(series, seriesIndex, labelAnchors, dataLabels); + AppendDataLabels(series, seriesIndex, labelAnchors, dataLabels, chart.Culture); } if (stackLines.ChildNodes.Count != 0) @@ -58,6 +61,33 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm } } + /// + /// A clip path covering the inner plot, as a clip-path reference. + /// + /// + /// Data beyond an explicit axis minimum or maximum was drawn straight through the axis and + /// over the chart around it. The Microsoft chart control clips series to the plot. A pixel of + /// slack either side keeps a line lying exactly on the plot edge whole. + /// + private string PlotClip(PlotGeometry geometry, XmlElement defs) + { + const string id = "innerPlotClip"; + const double slack = 1; + + var rect = _canvas.Element("rect"); + rect.SetAttribute("x", SvgCanvas.N(-slack)); + rect.SetAttribute("y", SvgCanvas.N(-slack)); + rect.SetAttribute("width", SvgCanvas.N(geometry.Width + (2 * slack))); + rect.SetAttribute("height", SvgCanvas.N(geometry.Height + (2 * slack))); + + var clipPath = _canvas.Element("clipPath"); + clipPath.SetAttribute("id", id); + clipPath.AppendChild(rect); + defs.AppendChild(clipPath); + + return $"url(#{id})"; + } + /// /// The gap between a data label and the thing it labels, as a fraction of the label's font size. /// @@ -75,7 +105,7 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm /// column, bar or line chart produced a chart with no labels and no error. A series with no /// label text draws no labels, as in the Microsoft chart control. /// - private void AppendDataLabels(Series series, int seriesIndex, List anchors, XmlElement dataLabels) + private void AppendDataLabels(Series series, int seriesIndex, List anchors, XmlElement dataLabels, CultureInfo culture) { if (series.LabelText is not { Length: > 0 } || anchors.Count == 0) { @@ -89,12 +119,18 @@ private void AppendDataLabels(Series series, int seriesIndex, List 0 }) { continue; } + if (series.LabelBackColor is { A: > 0 } background) + { + dataLabels.AppendChild( + _canvas.TextBackground(anchor.X, anchor.Y, text, anchor.HorizontalAlignment, anchor.VerticalAlignment, style, background)); + } + dataLabels.AppendChild( _canvas.Text( FormattableString.Invariant($"series{seriesIndex}Label{pointIndex++}"), diff --git a/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs b/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs index 7e164e5..9794267 100644 --- a/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs +++ b/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs @@ -146,6 +146,55 @@ private static string TextAnchor(HorizontalAlignment horizontalAlignment) _ => throw new NotSupportedException($"Unsupported HorizontalAlignment {horizontalAlignment}.") }; + /// + /// A filled rectangle behind a piece of text drawn by with the same arguments. + /// + /// + /// Sized from the measured text with a little padding, and placed from the same alignment the + /// text is, so the two stay together whichever way the label is anchored. + /// + internal XmlElement TextBackground( + double x, + double y, + string text, + HorizontalAlignment horizontalAlignment, + VerticalAlignment verticalAlignment, + TextStyle style, + Color color) + { + const double padding = 2; + var width = TextMeasure.Width(text, style.FontSize, style.FontWeight) + (2 * padding); + var height = style.FontSize * 1.2; + + var left = horizontalAlignment switch + { + HorizontalAlignment.Left => x - padding, + HorizontalAlignment.Right => x - width + padding, + _ => x - (width / 2) + }; + + // From where the glyphs' visual centre ends up for each alignment; see BaselineOffset. + var centre = verticalAlignment switch + { + VerticalAlignment.Top => y + (style.FontSize * 0.5), + VerticalAlignment.Bottom => y - (style.FontSize * 0.3), + _ => y + }; + + var rect = Element("rect"); + rect.SetAttribute("x", N(left)); + rect.SetAttribute("y", N(centre - (height / 2))); + rect.SetAttribute("width", N(width)); + rect.SetAttribute("height", N(height)); + rect.SetAttribute("fill", color.ToHex()); + if (color.A != 255) + { + rect.SetAttribute("fill-opacity", (color.A / 255f).ToString("F2", CultureInfo.InvariantCulture)); + } + + return rect; + } + internal double RelativePositionY(ChartNamedElement chartNamedElement, double yPositionPercent) => heightPixels * (100 - (yPositionPercent * chartNamedElement.GetCanvasHeightPercent() / 100)) / 100; @@ -176,7 +225,7 @@ internal XmlElement Line( lineNode.SetAttribute("stroke-width", width.ToString(CultureInfo.InvariantCulture)); - var dashArray = DashArrayFor(dashStyle); + var dashArray = DashArrayFor(dashStyle, width); if (dashArray is not null) { lineNode.SetAttribute("stroke-dasharray", dashArray); @@ -186,20 +235,27 @@ internal XmlElement Line( } /// - /// The dash pattern for a style, or null for a solid line. + /// The dash pattern for a style at a line width, or null for a solid line. /// /// - /// The same patterns the series paths use, so an axis line dashed the same way as a series - /// looks the same. + /// The GDI+ patterns the Microsoft chart control draws with, which are in units of the pen + /// width: a 4 pixel dashed border has 12 pixel dashes. Fixed pixel patterns made a thick dashed + /// line read as solid. /// - private static string? DashArrayFor(ChartDashStyle dashStyle) => dashStyle switch + internal static string? DashArrayFor(ChartDashStyle dashStyle, double width) { - ChartDashStyle.Dash => "5,2", - ChartDashStyle.DashDot => "5,2,1,2", - ChartDashStyle.DashDotDot => "5,2,1,2,1,2", - ChartDashStyle.Dot => "1,2", - _ => null - }; + double[]? pattern = dashStyle switch + { + ChartDashStyle.Dash => [3, 1], + ChartDashStyle.DashDot => [3, 1, 1, 1], + ChartDashStyle.DashDotDot => [3, 1, 1, 1, 1, 1], + ChartDashStyle.Dot => [1, 1], + _ => null + }; + + var unit = Math.Max(width, 1); + return pattern is null ? null : string.Join(",", pattern.Select(length => N(length * unit))); + } /// /// A coordinate formatted for a path, to two decimal places and culture-independently. @@ -212,9 +268,33 @@ internal XmlElement Line( internal static string N(double value) => value.ToString("F2", CultureInfo.InvariantCulture); /// - /// A positioned group for an element, translated into place. + /// An element's border alone, inset by half its width so it lies inside the image. /// - /// The element the group represents. + /// + /// For the chart's outer border, which is drawn after everything else, as the Microsoft chart + /// control draws its Borderline: drawn with the background, the legend's box covered it. + /// + internal XmlElement? Border(ChartNamedElement element) + { + if (element.StrokeColor == Colors.Transparent || element.StrokeWidth <= 0) + { + return null; + } + + var inset = element.StrokeWidth / 2; + var rect = Element("rect"); + rect.SetAttribute("id", "chartBorder"); + rect.SetAttribute("x", N(inset)); + rect.SetAttribute("y", N(inset)); + rect.SetAttribute("width", N(Math.Max(0, widthPixels - element.StrokeWidth))); + rect.SetAttribute("height", N(Math.Max(0, heightPixels - element.StrokeWidth))); + rect.SetStyle(element, applyFill: false); + return rect; + } + + /// + /// A positioned group for an element, translated into place. + /// /// The element the group represents. /// The group id. /// /// The group this one is nested inside, when it is nested inside a positioned one. @@ -227,7 +307,8 @@ internal XmlElement Line( /// so the chart area starts 20% in, and the plot and its axes were displaced by a further /// 20% of the width: the last category fell off the canvas. /// - internal XmlElement PositionedGroup(ChartNamedElement element, string id, ChartElement? within = null) + /// Whether the element's border is drawn on its rectangle. + internal XmlElement PositionedGroup(ChartNamedElement element, string id, ChartElement? within = null, bool drawStroke = true) { var groupNode = Group(id); @@ -249,8 +330,23 @@ internal XmlElement PositionedGroup(ChartNamedElement element, string id, ChartE } var rectNode = Element("rect"); - rectNode.SetAttribute("width", (widthPixels * element.GetCanvasWidthPercent() / 100).ToString(CultureInfo.InvariantCulture)); - rectNode.SetAttribute("height", (heightPixels * element.GetCanvasHeightPercent() / 100).ToString(CultureInfo.InvariantCulture)); + var width = widthPixels * element.GetCanvasWidthPercent() / 100; + var height = heightPixels * element.GetCanvasHeightPercent() / 100; + + // A stroke is centred on the edge, so a border on an element filling the image lost half + // its width off the edge. Inset by half the stroke, the border lies inside the box, as the + // Microsoft chart control draws it. + if (drawStroke && element.StrokeColor != Colors.Transparent && element.StrokeWidth > 0) + { + var inset = element.StrokeWidth / 2; + rectNode.SetAttribute("x", inset.ToString(CultureInfo.InvariantCulture)); + rectNode.SetAttribute("y", inset.ToString(CultureInfo.InvariantCulture)); + width = Math.Max(0, width - element.StrokeWidth); + height = Math.Max(0, height - element.StrokeWidth); + } + + rectNode.SetAttribute("width", width.ToString(CultureInfo.InvariantCulture)); + rectNode.SetAttribute("height", height.ToString(CultureInfo.InvariantCulture)); if (element.XRadiusPixels != 0) { rectNode.SetAttribute("rx", element.XRadiusPixels.ToString(CultureInfo.InvariantCulture)); @@ -261,7 +357,7 @@ internal XmlElement PositionedGroup(ChartNamedElement element, string id, ChartE rectNode.SetAttribute("ry", element.YRadiusPixels.ToString(CultureInfo.InvariantCulture)); } - rectNode.SetStyle(element); + rectNode.SetStyle(element, applyStroke: drawStroke); groupNode.AppendChild(rectNode); if (debug) diff --git a/PanoramicData.ChartMagic/Renderers/TextMeasure.cs b/PanoramicData.ChartMagic/Renderers/TextMeasure.cs new file mode 100644 index 0000000..1408eab --- /dev/null +++ b/PanoramicData.ChartMagic/Renderers/TextMeasure.cs @@ -0,0 +1,109 @@ +using SkiaSharp; + +namespace PanoramicData.ChartMagic.Renderers; + +/// +/// How wide a piece of text is when drawn in the font this library renders with. +/// +/// +/// Measured with the embedded face, which is what every raster render draws with, so a layout +/// decided here matches the pixels. A character-count estimate cannot tell "Manchester" from +/// "Illinois", which is the difference between a legend entry fitting and being cut off. +/// +internal static class TextMeasure +{ + /// + /// Synthetic bold is drawn wider than the regular face it is made from. + /// + private const double BoldWidening = 1.06; + + /// + /// The width per character, as a fraction of the size, if the embedded face cannot be loaded. + /// + private const double FallbackAverageWidth = 0.55; + + internal static double Width(string text, double fontSize, FontWeight fontWeight = FontWeight.Normal) + { + if (string.IsNullOrEmpty(text) || fontSize <= 0) + { + return 0; + } + + var widening = fontWeight == FontWeight.Bold ? BoldWidening : 1; + var typeface = EmbeddedTypefaceProvider.Default; + if (typeface is null) + { + return text.Length * fontSize * FallbackAverageWidth * widening; + } + + using var font = new SKFont(typeface, (float)fontSize); + return font.MeasureText(text) * widening; + } + + /// + /// The text, shortened with an ellipsis until it fits the width; the text itself when it fits. + /// + /// + /// An ellipsis rather than a hard cut, so a reader can tell the label was shortened rather than + /// reading "Memor" as the name of the series. + /// + internal static string Fit(string text, double maximumWidth, double fontSize, FontWeight fontWeight = FontWeight.Normal) + { + if (Width(text, fontSize, fontWeight) <= maximumWidth) + { + return text; + } + + const string ellipsis = "..."; + for (var length = text.Length - 1; length > 0; length--) + { + var candidate = text[..length].TrimEnd() + ellipsis; + if (Width(candidate, fontSize, fontWeight) <= maximumWidth) + { + return candidate; + } + } + + return ellipsis; + } + + /// + /// Breaks text into lines of at most the threshold's length, at spaces where it can. + /// + /// + /// The Microsoft chart control's legend TextWrapThreshold: a count of characters, not a width. + /// A word longer than the threshold is kept whole on its own line, as that control does. + /// + internal static IReadOnlyList Wrap(string text, int threshold) + { + if (threshold <= 0 || text.Length <= threshold) + { + return [text]; + } + + var lines = new List(); + var line = new StringBuilder(); + foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.Length > 0 && line.Length + 1 + word.Length > threshold) + { + lines.Add(line.ToString()); + line.Clear(); + } + + if (line.Length > 0) + { + line.Append(' '); + } + + line.Append(word); + } + + if (line.Length > 0) + { + lines.Add(line.ToString()); + } + + return lines; + } +} diff --git a/PanoramicData.ChartMagic/Renderers/TickGenerator.cs b/PanoramicData.ChartMagic/Renderers/TickGenerator.cs index 6d495ec..58198b7 100644 --- a/PanoramicData.ChartMagic/Renderers/TickGenerator.cs +++ b/PanoramicData.ChartMagic/Renderers/TickGenerator.cs @@ -17,14 +17,19 @@ internal static class TickGenerator /// /// Ticks across a linear range, at a caller-supplied interval or a readable one. /// - internal static IReadOnlyList Linear(double min, double max, double? interval, int targetCount) + /// The start of the range. + /// The end of the range. + /// The step, or null for a readable one. + /// Roughly how many ticks to aim for without an interval. + /// A value the ticks are counted from, or null for multiples of the step. + internal static IReadOnlyList Linear(double min, double max, double? interval, int targetCount, double? anchor = null) { if (!IsPlottableRange(min, max)) { return [min]; } - var ticks = StepsAcross(min, max, UsableStep(min, max, interval, targetCount)); + var ticks = StepsAcross(min, max, UsableStep(min, max, interval, targetCount), anchor ?? 0); return ticks.Count == 0 ? [min] : ticks; } @@ -57,17 +62,19 @@ private static double UsableStep(double min, double max, double? interval, int t } /// - /// The multiples of the step that fall within the range, aligned to a multiple of it. + /// The steps from the anchor that fall within the range. /// - private static List StepsAcross(double min, double max, double step) + private static List StepsAcross(double min, double max, double step, double anchor) { - var decimals = DecimalsFor(step); + // The anchor's own decimals too, so steps of 4 from 5.5 keep their half. + var fraction = Math.Abs(anchor % 1); + var decimals = Math.Max(DecimalsFor(step), fraction == 0 ? 0 : DecimalsFor(fraction)); var ticks = new List(); // A tolerance of one part in a billion of the step, so a tick that lands exactly on - // the maximum is not dropped by floating-point drift. + // the minimum or the maximum is not dropped by floating-point drift. var tolerance = step * 1e-9; - for (var value = Math.Ceiling(min / step) * step; value <= max + tolerance; value += step) + for (var value = anchor + (Math.Ceiling(((min - anchor) / step) - 1e-9) * step); value <= max + tolerance; value += step) { ticks.Add(Math.Round(value, decimals)); if (ticks.Count >= MaximumTicks) diff --git a/version.json b/version.json index 08206e9..30a8dee 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/AArnott/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "9.1", + "version": "9.2", "publicReleaseRefSpec": [ "^refs/heads/main", "^refs/heads/main$",