From 995255ff4441ff13bd0780888eab22e2fab73ba4 Mon Sep 17 00:00:00 2001 From: Tim Molter Date: Tue, 23 Jun 2026 00:12:49 +0200 Subject: [PATCH] Allow bar chart data labels to be positioned outside the bars (#500) Restores the ability (present in 3.6.1) to place bar chart value labels outside the bars, which was lost when the configurable labelsPosition was added and clamped to [0, 1] (inside only). - CategoryStyler/HorizontalBarStyler: relax the labelsPosition cap so values greater than 1 are allowed; document the semantics. - PlotContent_Category/PlotContent_HorizontalBar: for labelsPosition > 1, draw the label outside the bar at a fixed pixel gap that does not scale with the bar's size: gap = (labelsPosition - 1) * 100 pixels. The inside [0, 1] behavior (fraction of the bar) is unchanged. - Compute the automatic label font color against the plot background, not the bar fill, when the label is drawn outside the bar so it stays legible (also fixes stack-sum labels). - AxisPair: reserve axis headroom automatically when labels are drawn outside so they are not clipped at the plot edge. - Demos: show outside labels in BarChart04 and HorizontalBarChart01, and add TestForIssue500 / TestForIssue500_HorizontalBar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../xchart/demo/charts/bar/BarChart04.java | 3 + .../horizontalbar/HorizontalBarChart01.java | 5 +- .../standalone/issues/TestForIssue500.java | 54 ++++++++++++++++++ .../issues/TestForIssue500_HorizontalBar.java | 56 +++++++++++++++++++ .../xchart/internal/chartpart/AxisPair.java | 31 ++++++++++ .../internal/chartpart/PlotContent_.java | 5 ++ .../chartpart/PlotContent_Category.java | 27 +++++++-- .../chartpart/PlotContent_HorizontalBar.java | 37 ++++++++---- .../knowm/xchart/style/CategoryStyler.java | 10 +++- .../xchart/style/HorizontalBarStyler.java | 11 ++-- 10 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500.java create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500_HorizontalBar.java diff --git a/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart04.java b/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart04.java index fae8b296c..3ca6d08c5 100644 --- a/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart04.java +++ b/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart04.java @@ -19,6 +19,7 @@ *
  • Missing point in series *
  • Manually setting y-axis min and max values *
  • Bar Chart Annotations + *
  • Data labels positioned on top of (outside) the bars *
  • Horizontal Legend OutsideS */ public class BarChart04 implements ExampleChart { @@ -47,6 +48,8 @@ public CategoryChart getChart() { chart.getStyler().setYAxisMin(5.0); chart.getStyler().setYAxisMax(70.0); chart.getStyler().setLabelsVisible(true); + // A value greater than 1 places the labels on top of (outside) the bars + chart.getStyler().setLabelsPosition(1.05); chart.getStyler().setPlotGridVerticalLinesVisible(false); chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideS); chart.getStyler().setLegendLayout(Styler.LegendLayout.Horizontal); diff --git a/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/horizontalbar/HorizontalBarChart01.java b/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/horizontalbar/HorizontalBarChart01.java index a807a85bf..05eea993c 100644 --- a/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/horizontalbar/HorizontalBarChart01.java +++ b/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/horizontalbar/HorizontalBarChart01.java @@ -19,6 +19,7 @@ *
  • Single series *
  • Place legend at Inside-NW position *
  • Bar Chart Annotations + *
  • Data labels positioned outside (beyond the end of) the bars */ public class HorizontalBarChart01 implements ExampleChart { @@ -44,7 +45,9 @@ public HorizontalBarChart getChart() { // Customize Chart chart.getStyler().setLegendPosition(LegendPosition.InsideNW); - chart.getStyler().setLabelsVisible(false); + chart.getStyler().setLabelsVisible(true); + // A value greater than 1 places the labels outside (beyond the end of) the bars + chart.getStyler().setLabelsPosition(1.1); chart.getStyler().setPlotGridLinesVisible(false); // Series diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500.java new file mode 100644 index 000000000..85e3a754d --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500.java @@ -0,0 +1,54 @@ +package org.knowm.xchart.standalone.issues; + +import org.knowm.xchart.CategoryChart; +import org.knowm.xchart.CategoryChartBuilder; +import org.knowm.xchart.SwingWrapper; +import org.knowm.xchart.demo.charts.ExampleChart; +import org.knowm.xchart.style.Styler.LegendPosition; + +/** + * Issue #500: data labels can be placed outside (on top of) the bars again, as in 3.6.1. + * + *

    A labels position greater than 1 draws the label outside the bar (above positive bars, below + * negative bars). The automatic label font color is computed against the plot background, so the + * labels stay legible without setting a color manually. + */ +public class TestForIssue500 implements ExampleChart { + + public static void main(String[] args) { + ExampleChart exampleChart = new TestForIssue500(); + CategoryChart chart = exampleChart.getChart(); + new SwingWrapper(chart).displayChart(); + } + + @Override + public CategoryChart getChart() { + + // Create Chart + CategoryChart chart = + new CategoryChartBuilder() + .width(800) + .height(600) + .title("TestForIssue500") + .xAxisTitle("x") + .yAxisTitle("y") + .build(); + + // Customize Chart + chart.getStyler().setLegendPosition(LegendPosition.InsideNW); + chart.getStyler().setLabelsVisible(true); + // A value greater than 1 places the labels outside (above/below) the bars + chart.getStyler().setLabelsPosition(1.1); + + // Series + chart.addSeries("test 1", new double[] {0, 1, 2, 3, 4}, new double[] {4, 5, -7, 6, -5}); + + return chart; + } + + @Override + public String getExampleChartName() { + + return getClass().getSimpleName(); + } +} diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500_HorizontalBar.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500_HorizontalBar.java new file mode 100644 index 000000000..6f9d8c6d8 --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue500_HorizontalBar.java @@ -0,0 +1,56 @@ +package org.knowm.xchart.standalone.issues; + +import java.util.Arrays; +import org.knowm.xchart.HorizontalBarChart; +import org.knowm.xchart.HorizontalBarChartBuilder; +import org.knowm.xchart.SwingWrapper; +import org.knowm.xchart.demo.charts.ExampleChart; +import org.knowm.xchart.style.Styler.LegendPosition; + +/** + * Issue #500: data labels can be placed outside the bars for horizontal bar charts too. + * + *

    A labels position greater than 1 draws the label beyond the end of the bar. The automatic label + * font color is computed against the plot background, so the labels stay legible without setting a + * color manually. + */ +public class TestForIssue500_HorizontalBar implements ExampleChart { + + public static void main(String[] args) { + ExampleChart exampleChart = new TestForIssue500_HorizontalBar(); + HorizontalBarChart chart = exampleChart.getChart(); + new SwingWrapper<>(chart).displayChart(); + } + + @Override + public HorizontalBarChart getChart() { + + // Create Chart + HorizontalBarChart chart = + new HorizontalBarChartBuilder() + .width(800) + .height(600) + .title("TestForIssue500_HorizontalBar") + .yAxisTitle("Score") + .xAxisTitle("Number") + .build(); + + // Customize Chart + chart.getStyler().setLegendPosition(LegendPosition.InsideNW); + chart.getStyler().setPlotGridLinesVisible(false); + chart.getStyler().setLabelsVisible(true); + // A value greater than 1 places the labels outside (beyond the end of) the bars + chart.getStyler().setLabelsPosition(1.1); + + // Series + chart.addSeries("test 1", Arrays.asList(4, 5, -7, 6, -5), Arrays.asList(0, 1, 2, 3, 4)); + + return chart; + } + + @Override + public String getExampleChartName() { + + return getClass().getSimpleName(); + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisPair.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisPair.java index db3f19248..79e411648 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisPair.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisPair.java @@ -16,6 +16,10 @@ public class AxisPair implements ChartPart { + // Fraction of the axis range reserved as headroom when data labels are drawn outside the bars, so + // they are not clipped at the plot edge. + private static final double OUTSIDE_LABELS_AXIS_PADDING = 0.05; + private final AxesChart chart; private final Axis_X xAxis; @@ -255,12 +259,26 @@ private void overrideMinMaxForXAxis() { double overrideXAxisMaxValue = xAxis.getMax(); if (chart.getStyler() instanceof HorizontalBarStyler) { + HorizontalBarStyler horizontalBarStyler = (HorizontalBarStyler) chart.getStyler(); if (xAxis.getMin() > 0.0) { overrideXAxisMinValue = 0.0; } if (xAxis.getMax() < 0.0) { overrideXAxisMaxValue = 0.0; } + + // When labels are drawn outside the bars, reserve axis headroom so they are not clipped at + // the plot edge. + if (horizontalBarStyler.isLabelsVisible() && horizontalBarStyler.getLabelsPosition() > 1) { + double extra = + (overrideXAxisMaxValue - overrideXAxisMinValue) * OUTSIDE_LABELS_AXIS_PADDING; + if (overrideXAxisMaxValue > 0.0) { + overrideXAxisMaxValue += extra; + } + if (overrideXAxisMinValue < 0.0) { + overrideXAxisMinValue -= extra; + } + } } // override min and maxValue if specified @@ -354,6 +372,19 @@ private void overrideMinMaxForYAxis(Axis_Y yAxis) { if (yAxis.getMax() < 0.0) { overrideYAxisMaxValue = 0.0; } + + // When labels are drawn outside the bars, reserve axis headroom so they are not clipped at + // the plot edge. + if (categoryStyler.isLabelsVisible() && categoryStyler.getLabelsPosition() > 1) { + double extra = + (overrideYAxisMaxValue - overrideYAxisMinValue) * OUTSIDE_LABELS_AXIS_PADDING; + if (overrideYAxisMaxValue > 0.0) { + overrideYAxisMaxValue += extra; + } + if (overrideYAxisMinValue < 0.0) { + overrideYAxisMinValue -= extra; + } + } } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_.java index 43051093e..f421fa042 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_.java @@ -16,6 +16,11 @@ public abstract class PlotContent_ implemen static final BasicStroke ERROR_BAR_STROKE = new BasicStroke(1.0f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL); + // Converts the fraction of a labels position above 1 into a fixed pixel gap outside the bar, so + // the gap does not scale with the bar's size (e.g. a position of 1.1 places the label 10px out). + // Also used when reserving axis headroom so outside labels are not clipped at the plot edge. + static final double OUTSIDE_LABELS_OFFSET_SCALE = 100; + /** * Constructor * diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java index 569721ebc..322baba39 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java @@ -701,24 +701,39 @@ private void drawLabels( } else { labelX = xOffset + barWidth / 2 - labelRectangle.getWidth() / 2 - 1; } + double labelsPosition = stylerCategory.getLabelsPosition(); double labelY; if (showStackSum) { labelY = yOffset - 4; - } else { + } else if (labelsPosition <= 1) { + // inside the bar: the position is a fraction of the bar's height if (next >= 0.0) { labelY = yOffset - + (zeroOffset - yOffset) * (1 - stylerCategory.getLabelsPosition()) - + labelRectangle.getHeight() * stylerCategory.getLabelsPosition(); + + (zeroOffset - yOffset) * (1 - labelsPosition) + + labelRectangle.getHeight() * labelsPosition; } else { labelY = zeroOffset - - (zeroOffset - yOffset) * (1 - stylerCategory.getLabelsPosition()) - + labelRectangle.getHeight() * (1 - stylerCategory.getLabelsPosition()); + - (zeroOffset - yOffset) * (1 - labelsPosition) + + labelRectangle.getHeight() * (1 - labelsPosition); + } + } else { + // outside the bar: a fixed pixel gap beyond the bar's edge, independent of the bar's height + double outsideOffset = (labelsPosition - 1) * OUTSIDE_LABELS_OFFSET_SCALE; + if (next >= 0.0) { + labelY = yOffset - outsideOffset; + } else { + labelY = zeroOffset + outsideOffset + labelRectangle.getHeight(); } } if (stylerCategory.isLabelsFontColorAutomaticEnabled()) { - g.setColor(stylerCategory.getLabelsFontColor(seriesColor)); + // When the label is drawn outside the bar it sits on the plot background, not on the bar, so + // the automatic contrast color must be computed against the plot background color. + boolean labelOutsideBar = showStackSum || labelsPosition > 1; + Color contrastBackgroundColor = + labelOutsideBar ? stylerCategory.getPlotBackgroundColor() : seriesColor; + g.setColor(stylerCategory.getLabelsFontColor(contrastBackgroundColor)); } else { g.setColor(stylerCategory.getLabelsFontColor()); } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java index f8ce04a3a..8e937535a 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java @@ -226,22 +226,37 @@ private void drawLabels( } else { labelY = yOffset + barHeight / 2 + labelRectangle.getHeight() / 2; } + double labelsPosition = styler.getLabelsPosition(); double labelX; - - if (next.doubleValue() >= 0.0) { - labelX = - xOffset - + (zeroOffset - xOffset) * (1 - styler.getLabelsPosition()) - - labelRectangle.getWidth() * styler.getLabelsPosition(); + if (labelsPosition <= 1) { + // inside the bar: the position is a fraction of the bar's length + if (next.doubleValue() >= 0.0) { + labelX = + xOffset + + (zeroOffset - xOffset) * (1 - labelsPosition) + - labelRectangle.getWidth() * labelsPosition; + } else { + labelX = + zeroOffset + - (zeroOffset - xOffset) * (1 - labelsPosition) + - labelRectangle.getWidth() * (1 - labelsPosition); + } } else { - labelX = - zeroOffset - - (zeroOffset - xOffset) * (1 - styler.getLabelsPosition()) - - labelRectangle.getWidth() * (1 - styler.getLabelsPosition()); + // outside the bar: a fixed pixel gap beyond the bar's end, independent of the bar's length + double outsideOffset = (labelsPosition - 1) * OUTSIDE_LABELS_OFFSET_SCALE; + if (next.doubleValue() >= 0.0) { + labelX = xOffset + outsideOffset; + } else { + labelX = zeroOffset - outsideOffset - labelRectangle.getWidth(); + } } if (styler.isLabelsFontColorAutomaticEnabled()) { - g.setColor(styler.getLabelsFontColor(seriesColor)); + // When the label is drawn outside the bar it sits on the plot background, not on the bar, so + // the automatic contrast color must be computed against the plot background color. + Color contrastBackgroundColor = + labelsPosition > 1 ? styler.getPlotBackgroundColor() : seriesColor; + g.setColor(styler.getLabelsFontColor(contrastBackgroundColor)); } else { g.setColor(styler.getLabelsFontColor()); } diff --git a/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java b/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java index 56ffa53d5..2929c09e1 100644 --- a/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java +++ b/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java @@ -211,7 +211,11 @@ public double getLabelsPosition() { } /** - * A number between 0 and 1 setting the vertical position of the data label. Default is 0.5 + * Sets the vertical position of the data label relative to the bar. For values between 0 and 1 the + * label is drawn inside the bar as a fraction of its height: 0 places it at the baseline and 1 at + * the top of the bar. Values greater than 1 draw the label outside the bar (above positive bars, + * below negative bars) at a fixed pixel gap that does not scale with the bar's height, where the + * gap is (labelsPosition - 1) * 100 pixels (e.g. 1.1 places it 10px outside). Default is 0.5, * placing it in the center. * * @param labelsPosition @@ -219,8 +223,8 @@ public double getLabelsPosition() { */ public CategoryStyler setLabelsPosition(double labelsPosition) { - if (labelsPosition < 0 || labelsPosition > 1) { - throw new IllegalArgumentException("Annotations position must between 0 and 1!!!"); + if (labelsPosition < 0) { + throw new IllegalArgumentException("Labels position must be greater than or equal to 0!!!"); } this.labelsPosition = labelsPosition; return this; diff --git a/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java b/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java index 2e8729abf..3e4417265 100644 --- a/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java +++ b/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java @@ -132,16 +132,19 @@ public double getLabelsPosition() { } /** - * A number between 0 and 1 setting the vertical position of the data label. Default is 0.5 - * placing it in the center. + * Sets the horizontal position of the data label relative to the bar. For values between 0 and 1 + * the label is drawn inside the bar as a fraction of its length: 0 places it at the baseline and 1 + * at the end of the bar. Values greater than 1 draw the label outside the bar (beyond the end) at + * a fixed pixel gap that does not scale with the bar's length, where the gap is (labelsPosition - + * 1) * 100 pixels (e.g. 1.1 places it 10px outside). Default is 0.5, placing it in the center. * * @param labelsPosition * @return */ public HorizontalBarStyler setLabelsPosition(double labelsPosition) { - if (labelsPosition < 0 || labelsPosition > 1) { - throw new IllegalArgumentException("Annotations position must between 0 and 1!!!"); + if (labelsPosition < 0) { + throw new IllegalArgumentException("Labels position must be greater than or equal to 0!!!"); } this.labelsPosition = labelsPosition; return this;