diff --git a/PanoramicData.ChartMagic.Test/DataLabelAndFunnelTests.cs b/PanoramicData.ChartMagic.Test/DataLabelAndFunnelTests.cs new file mode 100644 index 0000000..f650651 --- /dev/null +++ b/PanoramicData.ChartMagic.Test/DataLabelAndFunnelTests.cs @@ -0,0 +1,226 @@ +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; + +/// +/// Data labels on charts drawn against axes, and funnel charts. +/// +/// +/// Both rendered nothing and reported success. Label text was drawn only for pies, so a column, +/// bar or line chart asking for #VAL labels had none; and Funnel was a chart type with nothing to +/// draw it, so a funnel came out as an empty plot. The placements asserted here are the ones +/// measured against DocMagic on Magic Suite's [List.Graph:] examples, and every assertion was +/// checked against the renderer before the change to confirm that it fails there. +/// +public class DataLabelAndFunnelTests +{ + private static List DataLabels(XDocument document) + => FindGroupById(document, "dataLabels") is { } group ? Elements(group, "text") : []; + + private static double Y(XElement element) => Number(element, "y"); + + private static double X(XElement element) => Number(element, "x"); + + // --------------------------------------------------------------------- + // Data labels + // --------------------------------------------------------------------- + + [Fact] + public void ColumnChart_WithLabelText_LabelsEveryColumnWithItsValue() + { + var specification = SingleSeries(SeriesChartType.Column, Points(10, 24, 17, 31), s => s.LabelText = "#VAL"); + + DataLabels(Render(specification)).Select(label => label.Value) + .Should().Equal(["10", "24", "17", "31"], "#VAL is the Microsoft chart shorthand for #VALY"); + } + + [Fact] + public void ColumnChart_WithoutLabelText_DrawsNoLabels() + => DataLabels(Render(SingleSeries(SeriesChartType.Column, Points(10, 24, 17, 31)))) + .Should().BeEmpty("the Microsoft chart control draws no data labels unless asked to"); + + [Fact] + public void ColumnChart_LabelSitsCentredAboveItsColumn() + { + var document = Render(SingleSeries(SeriesChartType.Column, Points(10, 24, 17, 31), s => s.LabelText = "#VAL")); + var column = Elements(GroupById(document, "series0"), "rect")[0]; + var label = DataLabels(document)[0]; + + label.Attribute("text-anchor")!.Value.Should().Be("middle"); + X(label).Should().BeApproximately(X(column) + (Number(column, "width") / 2), 0.5); + Y(label).Should().BeLessThan(Y(column), "a column's label sits above its top"); + } + + [Fact] + public void BarChart_LabelSitsBeyondTheEndOfItsBar_ReadingOutwards() + { + var document = Render(SingleSeries(SeriesChartType.Bar, Points(10, 24, 17, 31), s => s.LabelText = "#VALX")); + var bar = Elements(GroupById(document, "series0"), "rect")[0]; + var label = DataLabels(document)[0]; + + label.Value.Should().Be("Jan", "#VALX is the category"); + label.Attribute("text-anchor")!.Value.Should().Be("start"); + X(label).Should().BeGreaterThan(X(bar) + Number(bar, "width"), "a bar's label sits beyond its end"); + } + + [Fact] + public void LineChart_LabelSitsAboveItsPoint() + { + var document = Render(SingleSeries(SeriesChartType.Line, Points(10, 24, 17, 31), s => s.LabelText = "#VALY")); + + var labels = DataLabels(document); + labels.Select(label => label.Value).Should().Equal("10", "24", "17", "31"); + + // The largest value is drawn highest, so its label is the highest too. + labels.MinBy(Y)!.Value.Should().Be("31"); + } + + [Fact] + public void StackedColumn_LabelSitsInsideItsSegment() + { + var specification = ColumnChart(SeriesChartType.StackedColumn, 2); + specification.SeriesList.ForEach(series => series.LabelText = "#VAL"); + + var document = Render(specification); + var segment = Elements(GroupById(document, "series0"), "rect")[0]; + var label = DataLabels(document)[0]; + + Y(label).Should().BeInRange(Y(segment), Y(segment) + Number(segment, "height")); + } + + [Fact] + public void DataLabels_SubstituteEveryKeyword() + { + var specification = SingleSeries( + SeriesChartType.Column, + Points(25, 75), + s => s.LabelText = "#VALX #VALY #VAL #PERCENT #TOTAL"); + + DataLabels(Render(specification)).Select(label => label.Value) + .Should().Equal("Jan 25 25 25.00% 100", "Feb 75 75 75.00% 100"); + } + + [Fact] + public void PieChart_AlsoSubstitutesVal() + { + // #VAL was never substituted for a pie either: it is a prefix of #VALX and #VALY, which + // were, and it was left on the chart as the literal text "#VAL". + var specification = new ChartSpecification + { + SeriesList = + [ + new() { ChartType = SeriesChartType.Pie, LabelText = "#VAL", Points = Points(40, 60) } + ] + }; + + Elements(GroupById(Render(specification), "pie"), "text").Select(label => label.Value) + .Should().Equal("40", "60"); + } + + // --------------------------------------------------------------------- + // Funnels + // --------------------------------------------------------------------- + + private static ChartSpecification Funnel(string? labelText = null) + => new() + { + SeriesList = + [ + new() + { + ChartType = SeriesChartType.Funnel, + LabelText = labelText, + Points = + [ + new ChartPoint("Prospect", 0, 100000, Color.SteelBlue), + new ChartPoint("Opportunity", 1, 30000, Color.SeaGreen), + new ChartPoint("Quoted", 2, 30000, Color.Goldenrod) + ] + } + ] + }; + + private static List Segments(XDocument document) + => Elements(GroupById(document, "funnel"), "path"); + + /// The top and bottom of a segment, from its outline. + private static (double Top, double Bottom) VerticalExtent(XElement segment) + { + var ys = segment.Attribute("d")!.Value + .Split(['M', 'L', 'Z'], StringSplitOptions.RemoveEmptyEntries) + .Select(pair => double.Parse(pair.Trim().Split(' ')[1], CultureInfo.InvariantCulture)) + .ToList(); + return (ys.Min(), ys.Max()); + } + + private static double WidthAtTop(XElement segment) + { + var points = segment.Attribute("d")!.Value + .Split(['M', 'L', 'Z'], StringSplitOptions.RemoveEmptyEntries) + .Select(pair => pair.Trim().Split(' ').Select(n => double.Parse(n, CultureInfo.InvariantCulture)).ToArray()) + .ToList(); + return points[1][0] - points[0][0]; + } + + [Fact] + public void Funnel_DrawsOneSegmentPerPoint_InThePointColours() + { + var segments = Segments(Render(Funnel())); + + segments.Should().HaveCount(3); + segments.Select(segment => segment.Attribute("fill")!.Value) + .Should().Equal(Color.SteelBlue.ToHex(), Color.SeaGreen.ToHex(), Color.Goldenrod.ToHex()); + } + + [Fact] + public void Funnel_SegmentHeights_AreProportionalToTheValues() + { + // FunnelStyle YIsHeight, the Microsoft chart default: 100000 against 30000. + var heights = Segments(Render(Funnel())) + .Select(VerticalExtent) + .Select(extent => extent.Bottom - extent.Top) + .ToList(); + + (heights[0] / heights[1]).Should().BeApproximately(100000d / 30000d, 0.01); + heights[1].Should().BeApproximately(heights[2], 0.01); + } + + [Fact] + public void Funnel_SegmentsAreStackedTopToBottom_AndNarrowDownwards() + { + var segments = Segments(Render(Funnel())); + + VerticalExtent(segments[0]).Bottom.Should().BeApproximately(VerticalExtent(segments[1]).Top, 0.01); + VerticalExtent(segments[1]).Bottom.Should().BeApproximately(VerticalExtent(segments[2]).Top, 0.01); + WidthAtTop(segments[0]).Should().BeGreaterThan(WidthAtTop(segments[1])); + WidthAtTop(segments[1]).Should().BeGreaterThan(WidthAtTop(segments[2])); + } + + [Fact] + public void Funnel_LabelsEachSegmentFromTheLabelText_ToTheRightOfTheFunnel() + { + var document = Render(Funnel("#VALX: GBP #VALY")); + var funnel = GroupById(document, "funnel"); + + var labels = Elements(funnel, "text"); + labels.Select(label => label.Value) + .Should().Equal("Prospect: GBP 100000", "Opportunity: GBP 30000", "Quoted: GBP 30000"); + + var rightmostSegmentEdge = Segments(document) + .SelectMany(segment => segment.Attribute("d")!.Value + .Split(['M', 'L', 'Z'], StringSplitOptions.RemoveEmptyEntries) + .Select(pair => double.Parse(pair.Trim().Split(' ')[0], CultureInfo.InvariantCulture))) + .Max(); + labels.Should().AllSatisfy(label => X(label).Should().BeGreaterThan(rightmostSegmentEdge)); + + Elements(funnel, "line").Should().HaveCount(3, "each label is joined to its segment by a leader line"); + } + + [Fact] + public void Funnel_DrawsNoAxes() + => FindGroupById(Render(Funnel()), "xAxis").Should().BeNull("a funnel is not drawn against axes"); +} diff --git a/PanoramicData.ChartMagic/Renderers/DataLabelText.cs b/PanoramicData.ChartMagic/Renderers/DataLabelText.cs new file mode 100644 index 0000000..eddfdfe --- /dev/null +++ b/PanoramicData.ChartMagic/Renderers/DataLabelText.cs @@ -0,0 +1,61 @@ +namespace PanoramicData.ChartMagic.Renderers; + +/// +/// Turns a series' label text into the label for one point, replacing the Microsoft chart +/// keywords that appear in report templates. +/// +/// +/// This used to live in the pie renderer and serve pies alone, so label text on any other chart +/// type drew nothing at all. It is shared now by pies, funnels and every series drawn against +/// axes. +/// +internal static class DataLabelText +{ + /// + /// The label for a point, or null when the series has no label text. + /// + /// The series' label text. + /// The point being labelled. + /// The value drawn for the point. + /// The name of the series the point belongs to. + /// The point's share of the series total, 0 to 100. + /// The series total. + /// + /// 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 + /// instead of vanishing silently. + /// + /// #VAL is the Microsoft chart shorthand for #VALY. It is replaced after #VALX and #VALY, + /// because it is a prefix of both. + /// + internal static string? Substitute( + string? text, + ChartPoint point, + double value, + string? seriesName, + double percentage, + double total) + { + if (text is not { Length: > 0 }) + { + return null; + } + + var formattedValue = FormatNumber(value); + + return text + .Replace("#VALX", point.XValueString ?? FormatNumber(point.XValue), 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("#LEGENDTEXT", point.LegendText ?? string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("#SERIESNAME", seriesName ?? string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("#SER", seriesName ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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); +} diff --git a/PanoramicData.ChartMagic/Renderers/FunnelRenderer.cs b/PanoramicData.ChartMagic/Renderers/FunnelRenderer.cs new file mode 100644 index 0000000..925f166 --- /dev/null +++ b/PanoramicData.ChartMagic/Renderers/FunnelRenderer.cs @@ -0,0 +1,194 @@ +using System.Drawing; + +namespace PanoramicData.ChartMagic.Renderers; + +/// +/// Funnels, which are drawn as a stack of segments narrowing to a neck rather than against axes. +/// +/// The document this draws into. +/// +/// +/// Funnel was a member of with nothing that drew it, so a funnel +/// chart rendered as an empty plot with a value axis and no error. +/// +/// +/// This draws the Microsoft chart control's default funnel, FunnelStyle YIsHeight, as measured +/// against DocMagic on the [List.Graph:] funnel example: the points are stacked top to bottom in +/// order, each segment's height is its share of the total, and the outline narrows in a straight +/// line from the full width at the top to a neck, which the last segment runs down into. Each +/// point takes its own colour, and its label sits in a column to the right of the funnel, joined +/// to the segment's edge by a leader line. +/// +/// +internal sealed class FunnelRenderer(SvgCanvas canvas) +{ + private readonly SvgCanvas _canvas = canvas; + + /// + /// The neck's width as a fraction of the funnel's top width. + /// + /// Measured against DocMagic: 70 pixels against a top width of 1041. + private const double NeckWidthFraction = 0.067; + + /// + /// The neck's height as a fraction of the funnel's height. + /// + /// Measured against DocMagic: 35 pixels against a height of 510. + private const double NeckHeightFraction = 0.069; + + /// + /// The space between a leader line's end and its label, in pixels. + /// + private const double LabelPaddingPixels = 6; + + /// + /// The average advance of a character as a fraction of the font size, used to reserve the + /// label column. + /// + /// + /// Deliberately generous. The Microsoft chart control reserves nothing, so a long label runs off + /// the edge of the image and is cut off; reserving too much only narrows the funnel. + /// + private const double CharacterWidthFraction = 0.6; + + /// + /// The widest the label column may be, as a fraction of the inner plot width. + /// + private const double MaximumLabelColumnFraction = 0.45; + + /// + /// The leader line colour. + /// + /// + /// The Microsoft chart control's CalloutLineColor default. A funnel has no setting of its own for + /// it, and the pie line colour belongs to pies: DocMagic draws funnel leader lines black + /// whatever that is set to. + /// + private static readonly Color LeaderLineColor = Color.Black; + + /// + /// Whether this series is drawn as a funnel. + /// + internal static bool IsFunnel(Series series) => series.ChartType == SeriesChartType.Funnel; + + /// + /// Draws the funnel: one segment per point, then the labels and their leader lines. + /// + /// The funnel series. + /// + /// The points as slices: the pie slice builder already resolves each point's value, share, + /// colour, label and legend text, which is everything a segment needs. Its angles are unused. + /// + /// The group the funnel is drawn into. + /// The inner plot width, in pixels. + /// The inner plot height, in pixels. + internal void Plot(Series series, List segments, XmlElement innerPlotNode, double plotWidth, double plotHeight) + { + if (segments.Count == 0) + { + return; + } + + var funnelNode = _canvas.Group("funnel"); + innerPlotNode.AppendChild(funnelNode); + + var labelled = segments.Exists(segment => segment.Label.Length > 0); + var labelColumnWidth = labelled + ? Math.Min( + plotWidth * MaximumLabelColumnFraction, + (segments.Max(segment => segment.Label.Length) * series.FontSize * CharacterWidthFraction) + LabelPaddingPixels) + : 0; + + var shape = new FunnelShape(plotWidth - labelColumnWidth, plotHeight); + var total = segments.Sum(segment => segment.Value); + var top = 0d; + var labelStyle = TextStyle.Unstroked(series.FontWeight, series.FontFamily, series.FontSize, series.FontColor); + + var segmentIndex = 0; + foreach (var segment in segments) + { + var bottom = top + (segment.Value / total * plotHeight); + + funnelNode.AppendChild(CreateSegment(shape, segment, top, bottom, segmentIndex)); + + if (segment.Label.Length > 0) + { + var middle = (top + bottom) / 2; + funnelNode.AppendChild( + _canvas.Line(shape.CentreX + shape.HalfWidthAt(middle), middle, shape.Width, middle, LeaderLineColor, 1)); + funnelNode.AppendChild( + _canvas.Text( + FormattableString.Invariant($"funnelLabel{segmentIndex}"), + shape.Width + LabelPaddingPixels, + middle, + segment.Label, + HorizontalAlignment.Left, + VerticalAlignment.Middle, + labelStyle)); + } + + top = bottom; + segmentIndex++; + } + } + + /// + /// One segment: the part of the funnel outline between two heights, filled in the point colour. + /// + private XmlElement CreateSegment(FunnelShape shape, PieSlice segment, double top, double bottom, int segmentIndex) + { + var outline = new List<(double X, double Y)> + { + (shape.CentreX - shape.HalfWidthAt(top), top), + (shape.CentreX + shape.HalfWidthAt(top), top) + }; + + // The segment the neck starts in turns a corner there, on each side. + var turnsIntoTheNeck = top < shape.NeckTop && bottom > shape.NeckTop; + if (turnsIntoTheNeck) + { + outline.Add((shape.CentreX + shape.NeckHalfWidth, shape.NeckTop)); + } + + outline.Add((shape.CentreX + shape.HalfWidthAt(bottom), bottom)); + outline.Add((shape.CentreX - shape.HalfWidthAt(bottom), bottom)); + + if (turnsIntoTheNeck) + { + outline.Add((shape.CentreX - shape.NeckHalfWidth, shape.NeckTop)); + } + + var path = _canvas.Element("path"); + path.SetAttribute("id", FormattableString.Invariant($"funnelSegment{segmentIndex}")); + path.SetAttribute( + "d", + "M" + string.Join(" L", outline.Select(p => $"{SvgCanvas.N(p.X)} {SvgCanvas.N(p.Y)}")) + " Z"); + path.SetAttribute("fill", segment.Color.ToHex()); + if (segment.Color.A != 255) + { + path.SetAttribute( + "fill-opacity", + (segment.Color.A / 255f).ToString("F2", CultureInfo.InvariantCulture)); + } + + return path; + } + + /// + /// The outline every segment is cut from: full width at the top, narrowing in a straight line + /// to the neck, then straight down. + /// + private sealed record FunnelShape(double Width, double Height) + { + internal double CentreX => Width / 2; + + internal double NeckHalfWidth => Width * NeckWidthFraction / 2; + + internal double NeckTop => Height * (1 - NeckHeightFraction); + + internal double HalfWidthAt(double y) + => y >= NeckTop + ? NeckHalfWidth + : (Width / 2) - (((Width / 2) - NeckHalfWidth) * y / NeckTop); + } +} diff --git a/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs b/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs index 0c5c229..a16be1a 100644 --- a/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/InternalSvgRenderer.cs @@ -33,7 +33,15 @@ internal void SaveImage(Stream stream, Chart chart) // A pie has no axes, so it takes a different path entirely: no gridlines, no axis // strips, and a legend that describes slices rather than series. var pieSeries = chart.Series.FirstOrDefault(PieRenderer.IsPie); - if (pieSeries is not null) + var funnelSeries = chart.Series.FirstOrDefault(FunnelRenderer.IsFunnel); + 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); + 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); new PieRenderer(_canvas).Plot(pieSeries, slices, innerPlotNode, geometry.Width, geometry.Height); diff --git a/PanoramicData.ChartMagic/Renderers/PieSlice.cs b/PanoramicData.ChartMagic/Renderers/PieSlice.cs index 8425e7e..5e6c085 100644 --- a/PanoramicData.ChartMagic/Renderers/PieSlice.cs +++ b/PanoramicData.ChartMagic/Renderers/PieSlice.cs @@ -167,7 +167,7 @@ private static PieSlice CollectedSlice(Series series, double collectedValue, dou private static string LabelFor(Series series, ChartPoint point, double value, double percentage, double total) => series.PieLabelStyle == Models.PieLabelStyle.Disabled ? string.Empty - : Substitute(series.LabelText, point, value, percentage, total) + : DataLabelText.Substitute(series.LabelText, point, value, series.Name, percentage, total) // 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. @@ -178,27 +178,4 @@ private static string LegendTextFor(ChartPoint point, double value) => point.LegendText ?? point.XValueString ?? value.ToString("0.##", CultureInfo.InvariantCulture); - - /// - /// Replaces the Microsoft chart keywords that appear in report templates. - /// - /// - /// The keyword set is deliberately small: these four 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 instead of vanishing silently. - /// - private static string? Substitute(string? text, ChartPoint point, double value, double percentage, double total) - { - if (text is not { Length: > 0 }) - { - return null; - } - - return text - .Replace("#VALX", point.XValueString ?? point.XValue.ToString("0.##", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase) - .Replace("#VALY", value.ToString("0.##", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase) - .Replace("#PERCENT", percentage.ToString("0.00", CultureInfo.InvariantCulture) + "%", StringComparison.OrdinalIgnoreCase) - .Replace("#TOTAL", total.ToString("0.##", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase) - .Replace("#LEGENDTEXT", point.LegendText ?? string.Empty, StringComparison.OrdinalIgnoreCase); - } } diff --git a/PanoramicData.ChartMagic/Renderers/RenderModels/DataLabelAnchor.cs b/PanoramicData.ChartMagic/Renderers/RenderModels/DataLabelAnchor.cs new file mode 100644 index 0000000..f9a0c50 --- /dev/null +++ b/PanoramicData.ChartMagic/Renderers/RenderModels/DataLabelAnchor.cs @@ -0,0 +1,18 @@ +namespace PanoramicData.ChartMagic.Renderers.RenderModels; + +/// +/// Where one point's data label goes, and which way its text runs from there. +/// +/// The point being labelled. +/// The value of the point, which is what #VAL and #VALY show. +/// The anchor, in pixels across the inner plot. +/// The anchor, in pixels down the inner plot. +/// How the text sits horizontally against the anchor. +/// How the text sits vertically against the anchor. +internal sealed record DataLabelAnchor( + ChartPoint Point, + double Value, + double X, + double Y, + HorizontalAlignment HorizontalAlignment, + VerticalAlignment VerticalAlignment); diff --git a/PanoramicData.ChartMagic/Renderers/RenderModels/SeriesTrace.cs b/PanoramicData.ChartMagic/Renderers/RenderModels/SeriesTrace.cs index 2b95146..4dd3dec 100644 --- a/PanoramicData.ChartMagic/Renderers/RenderModels/SeriesTrace.cs +++ b/PanoramicData.ChartMagic/Renderers/RenderModels/SeriesTrace.cs @@ -23,10 +23,15 @@ namespace PanoramicData.ChartMagic.Renderers.RenderModels; /// are collected rather than appended as they are found so that the caller decides which group /// they belong in. /// +/// +/// Where each point with a value was drawn, with the value it was drawn at, so that its data label +/// can be placed beside it once every series is down. +/// internal sealed record SeriesTrace( string LinePath, string AreaSegments, double? FirstXPosition, double LastXPosition, List<(double X, double Y)> ReturnPathPoints, - List MarkerNodes); + List MarkerNodes, + List LabelAnchors); diff --git a/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs b/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs index 8f99179..bd8be6f 100644 --- a/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs +++ b/PanoramicData.ChartMagic/Renderers/SeriesRenderer.cs @@ -14,12 +14,14 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm var stackedColumnTotals = new Dictionary(); var stackedAreaTotals = new Dictionary(); var stackLines = _canvas.Group("stackLines"); + var dataLabels = _canvas.Group("dataLabels"); var bands = BandLayout.For(chart); var seriesIndex = -1; foreach (var series in chart.Series) { var seriesNode = _canvas.Group($"series{++seriesIndex}"); + List labelAnchors; // Add markers to defs if required var seriesMarkerId = $"series{seriesIndex}Marker"; @@ -33,20 +35,76 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm if (PlotGeometry.IsBanded(series.ChartType)) { - PlotBandedSeries(chart, geometry, series, seriesNode, stackTotals, bands.SlotFor(series), bands.SlotCount); + labelAnchors = PlotBandedSeries(chart, geometry, series, seriesNode, stackTotals, bands.SlotFor(series), bands.SlotCount); } else { - PlotPointSeries(geometry, series, seriesNode, stackLines, stackTotals, seriesMarkerId); + labelAnchors = PlotPointSeries(geometry, series, seriesNode, stackLines, stackTotals, seriesMarkerId); } innerPlotNode.AppendChild(seriesNode); + AppendDataLabels(series, seriesIndex, labelAnchors, dataLabels); } if (stackLines.ChildNodes.Count != 0) { innerPlotNode.AppendChild(stackLines); } + + // Last, so that no series is drawn over another series' labels. + if (dataLabels.ChildNodes.Count != 0) + { + innerPlotNode.AppendChild(dataLabels); + } + } + + /// + /// The gap between a data label and the thing it labels, as a fraction of the label's font size. + /// + /// + /// Measured against DocMagic on the [List.Graph:] mixed column and line example: with 16px + /// labels, the bottom of each column label sat about 8px above the top of its column. + /// + private const double DataLabelGapFraction = 0.5; + + /// + /// Draws one series' data labels, from its label text, beside the points it drew. + /// + /// + /// Label text was carried on every series and drawn only for pies, so labelText=#VAL on a + /// 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) + { + if (series.LabelText is not { Length: > 0 } || anchors.Count == 0) + { + return; + } + + var total = anchors.Sum(anchor => Math.Abs(anchor.Value)); + var style = TextStyle.Unstroked(series.FontWeight, series.FontFamily, series.FontSize, series.FontColor); + + var pointIndex = 0; + foreach (var anchor in anchors) + { + var percentage = total == 0 ? 0 : Math.Abs(anchor.Value) / total * 100; + var text = DataLabelText.Substitute(series.LabelText, anchor.Point, anchor.Value, series.Name, percentage, total); + if (text is not { Length: > 0 }) + { + continue; + } + + dataLabels.AppendChild( + _canvas.Text( + FormattableString.Invariant($"series{seriesIndex}Label{pointIndex++}"), + anchor.X, + anchor.Y, + text, + anchor.HorizontalAlignment, + anchor.VerticalAlignment, + style)); + } } /// @@ -77,7 +135,8 @@ internal void PlotSeries(Chart chart, PlotGeometry geometry, XmlElement defs, Xm /// A stacked area draws its fill in its own group and its line in the shared one, so that /// every line is drawn over every fill rather than being buried by the next series. /// - private void PlotPointSeries( + /// Where each point's data label goes. + private List PlotPointSeries( PlotGeometry geometry, Series series, XmlElement seriesNode, @@ -97,6 +156,8 @@ private void PlotPointSeries( { AppendLinePath(lineTarget, series, trace); } + + return trace.LabelAnchors; } /// @@ -139,8 +200,13 @@ private SeriesTrace TracePoints( var lastXPosition = 0d; var returnPathPoints = new List<(double X, double Y)>(); var markerNodes = new List(); + var labelAnchors = new List(); var isFirstPoint = true; + // A label sits above its point, clear of the marker where there is one. + var labelLift = (series.MarkerStyle != MarkerStyle.None ? (series.MarkerSize ?? 0) / 2 : 0) + + (series.FontSize * DataLabelGapFraction); + foreach (var chartPoint in series.Points) { var yValue = StackedValue(geometry, chartPoint, stackTotals, out var previousYValue); @@ -165,6 +231,17 @@ private SeriesTrace TracePoints( { markerNodes.Add(_markers.CreateMarkerReference(markerId, xPosition, yPosition)); } + + if (chartPoint.YValue is { } pointValue) + { + labelAnchors.Add(new DataLabelAnchor( + chartPoint, + pointValue, + xPosition, + yPosition - labelLift, + HorizontalAlignment.Center, + VerticalAlignment.Bottom)); + } } return new SeriesTrace( @@ -173,7 +250,8 @@ private SeriesTrace TracePoints( firstXPosition, lastXPosition, returnPathPoints, - markerNodes); + markerNodes, + labelAnchors); } /// @@ -281,7 +359,8 @@ private void AppendLinePath(XmlElement target, Series series, SeriesTrace trace) /// column chart rendered its legend and nothing else - no exception, no empty-plot warning, /// just a blank plot area beside a correct-looking legend. /// - private void PlotBandedSeries( + /// Where each point's data label goes. + private List PlotBandedSeries( Chart chart, PlotGeometry geometry, Series series, @@ -295,10 +374,12 @@ private void PlotBandedSeries( var slotExtent = groupExtent / slotCount; var origin = geometry.ValueAxisOrigin; var isHorizontal = PlotGeometry.IsHorizontal(series.ChartType); + var labelAnchors = new List(); + var gap = series.FontSize * DataLabelGapFraction; foreach (var chartPoint in series.Points) { - if (chartPoint.YValue is null) + if (chartPoint.YValue is not { } pointValue) { continue; } @@ -309,7 +390,61 @@ private void PlotBandedSeries( var rectNode = CreateBandRect(isHorizontal, from, to, slotStart, slotExtent); rectNode.SetStyle(series); seriesNode.AppendChild(rectNode); + + labelAnchors.Add(BandLabelAnchor( + chartPoint, + pointValue, + isHorizontal, + stackTotals is not null, + from, + to, + slotStart + (slotExtent / 2), + gap)); } + + return labelAnchors; + } + + /// + /// Where a column or bar's data label goes. + /// + /// + /// As measured against DocMagic on the [List.Graph:] examples: a column's label is centred just + /// above its top, and a bar's is just beyond its end, reading outwards. A negative value's label + /// goes beyond its end on the other side. A stacked segment's label is centred in the segment, + /// because outside it would sit on the next segment up. + /// + private static DataLabelAnchor BandLabelAnchor( + ChartPoint point, + double value, + bool isHorizontal, + bool isStacked, + double from, + double to, + double across, + double gap) + { + if (isStacked) + { + var middle = (from + to) / 2; + return isHorizontal + ? new DataLabelAnchor(point, value, middle, across, HorizontalAlignment.Center, VerticalAlignment.Middle) + : new DataLabelAnchor(point, value, across, middle, HorizontalAlignment.Center, VerticalAlignment.Middle); + } + + // Pixel positions grow rightwards and downwards, so the far end of a positive bar is the + // larger X, and the far end of a positive column is the smaller Y. + var isNegative = value < 0; + if (isHorizontal) + { + return isNegative + ? new DataLabelAnchor(point, value, Math.Min(from, to) - gap, across, HorizontalAlignment.Right, VerticalAlignment.Middle) + : new DataLabelAnchor(point, value, Math.Max(from, to) + gap, across, HorizontalAlignment.Left, VerticalAlignment.Middle); + } + + return isNegative + ? new DataLabelAnchor(point, value, across, Math.Max(from, to) + gap, HorizontalAlignment.Center, VerticalAlignment.Top) + : new DataLabelAnchor(point, value, across, Math.Min(from, to) - gap, HorizontalAlignment.Center, VerticalAlignment.Bottom); } ///