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);
}
///