From 163a9f34e7b3152d96abcaa792eba13d5ab381cf Mon Sep 17 00:00:00 2001 From: David Bond Date: Thu, 1 Oct 2026 12:21:13 +0100 Subject: [PATCH 1/2] Draw gridlines, legends, axis titles and numbers as the Microsoft chart control does (fixes #77) Found by the Magic Suite renderer comparison (MS-26667). Axes and plot: - MajorGridInterval spaces major gridlines independently of the labels. - Gridline dash styles, and a minor gridline width of its own. - AxisArea.IntervalStartsAtMinimum counts ticks from an explicit minimum. - AxisArea.IsMarginVisible lets the categories run from end to end. - Series are clipped to the inner plot. - The Y axis title is shrunk to fit beside the tick labels, or left out. - Chart.Culture formats axis and data labels (C2, P0, decimals). - Series.LabelBackColor fills behind data labels. Legends: - Table style, laid out in as many columns as the height needs. - LegendItemOrder, Auto listing stacked series from the top of the stack. - TextWrapThreshold, 25 characters by default. - A row wraps instead of running off the legend; a short column keeps entries a line apart; a label too wide for the image gives up the inset, then is shortened with an ellipsis. - Pie and funnel legends honour the style. Text is measured with the embedded face for all of this. Version 9.2: new public API, and the legend order, text wrapping and plot clipping defaults change existing output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../LegendLayoutTests.cs | 173 ++++++- .../PlotLayoutTests.cs | 4 +- .../RendererFidelityTests.cs | 204 ++++++++ PanoramicData.ChartMagic/Models/AxisArea.cs | 27 + PanoramicData.ChartMagic/Models/Chart.cs | 9 + PanoramicData.ChartMagic/Models/Legend.cs | 11 +- .../Models/LegendItemOrder.cs | 19 + PanoramicData.ChartMagic/Models/Series.cs | 3 + .../Renderers/AxisRenderer.cs | 138 +++++- .../Renderers/DataLabelText.cs | 15 +- .../Renderers/EmbeddedTypefaceProvider.cs | 3 + .../Renderers/InternalSvgRenderer.cs | 6 +- .../Renderers/LegendRenderer.cs | 463 ++++++++++++------ .../Renderers/PieRenderer.cs | 6 + .../Renderers/PieSlice.cs | 12 +- .../Renderers/PlotGeometry.cs | 25 +- .../Renderers/SeriesRenderer.cs | 42 +- .../Renderers/SvgCanvas.cs | 49 ++ .../Renderers/TextMeasure.cs | 109 +++++ .../Renderers/TickGenerator.cs | 21 +- version.json | 2 +- 21 files changed, 1129 insertions(+), 212 deletions(-) create mode 100644 PanoramicData.ChartMagic.Test/RendererFidelityTests.cs create mode 100644 PanoramicData.ChartMagic/Models/LegendItemOrder.cs create mode 100644 PanoramicData.ChartMagic/Renderers/TextMeasure.cs 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..8d6dd92 100644 --- a/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs +++ b/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs @@ -175,7 +175,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 +184,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..b411dd8 --- /dev/null +++ b/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs @@ -0,0 +1,204 @@ +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 +{ + [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); + } + + [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/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..1c842f1 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); } diff --git a/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs b/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs index a5da061..6c50dd6 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 = 2; + + /// 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,325 @@ 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, 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. + /// + private static double InsetFor(LegendMetrics metrics, double widestText) + { + var standard = Math.Round(metrics.Width * LegendInsetFraction, 2); + var room = metrics.Width - metrics.SwatchWidth - (metrics.Padding / 2) - widestText; + return Math.Round(Math.Clamp(room, Math.Min(metrics.Padding / 2, standard), standard), 2); + } + + /// + /// 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 +395,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 +440,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..cbf07c6 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; 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$", From 7cb6e56885dfbadad366e06050bb13173a096d51 Mon Sep 17 00:00:00 2001 From: David Bond Date: Thu, 1 Oct 2026 12:45:08 +0100 Subject: [PATCH 2/2] Draw the chart border last and inside the image, with dashes scaled to its width (#77) - The outer border is drawn after the legend and annotations, so the legend's box no longer covers it, and inset by half its width so none of it falls off the image. Any other stroked element is inset the same way. - Dash patterns are the GDI+ ones in units of the line width, as the Microsoft chart control draws them, and dashed strokes use butt caps: round caps closed the gaps, so a thick dashed line read as solid. Dot was drawn as a dash. - A legend label gives up its inset against the image edge as well as the legend's, so "Manchester" fits a 144 pixel legend unshortened. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PlotLayoutTests.cs | 10 ++- .../RendererFidelityTests.cs | 36 +++++++++ .../Extensions/XmlElementExtensions.cs | 14 +--- .../Renderers/InternalSvgRenderer.cs | 7 +- .../Renderers/LegendRenderer.cs | 14 ++-- .../Renderers/SvgCanvas.cs | 81 +++++++++++++++---- 6 files changed, 127 insertions(+), 35 deletions(-) diff --git a/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs b/PanoramicData.ChartMagic.Test/PlotLayoutTests.cs index 8d6dd92..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)); } /// diff --git a/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs b/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs index b411dd8..ce235b9 100644 --- a/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs +++ b/PanoramicData.ChartMagic.Test/RendererFidelityTests.cs @@ -12,6 +12,8 @@ namespace PanoramicData.ChartMagic.Test; /// public class RendererFidelityTests { + private static readonly string[] Cities = ["London", "Manchester", "Leeds"]; + [Fact] public void CurrencyFormat_UsesTheChartCulturesSymbol() { @@ -190,6 +192,40 @@ public void LabelBackColor_IsDrawnBehindEachDataLabel() 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 })] 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/Renderers/InternalSvgRenderer.cs b/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs index 1c842f1..0ef8d71 100644 --- a/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs @@ -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 6c50dd6..5fec767 100644 --- a/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/LegendRenderer.cs @@ -22,7 +22,7 @@ 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 = 2; + 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; @@ -168,7 +168,7 @@ private List LayOutColumn( { 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, widestText); + var inset = InsetFor(metrics, Math.Min(metrics.Width, _canvas.WidthPixels - ImageEdgeGapPixels - bounds.Left), widestText); var maximumTextWidth = MaximumTextWidth(bounds, inset, metrics); var centres = SpreadDown(metrics, bounds, lines); @@ -184,11 +184,15 @@ private List LayOutColumn( /// The inset an entry starts at: the measured fraction of the legend, less whatever a long /// label needs to stay inside it. /// - private static double InsetFor(LegendMetrics metrics, double widestText) + /// 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 = metrics.Width - metrics.SwatchWidth - (metrics.Padding / 2) - widestText; - return Math.Round(Math.Clamp(room, Math.Min(metrics.Padding / 2, standard), standard), 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; } /// diff --git a/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs b/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs index cbf07c6..9794267 100644 --- a/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs +++ b/PanoramicData.ChartMagic/Renderers/SvgCanvas.cs @@ -225,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); @@ -235,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. @@ -261,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. @@ -276,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); @@ -298,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)); @@ -310,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)