From a1d9cbe747ed2b9f789453077ba8536ee4d2115d Mon Sep 17 00:00:00 2001 From: Tim Molter Date: Thu, 16 Jul 2026 12:43:13 +0200 Subject: [PATCH] Issue #503: apply axisTitlePadding to right-side Y axis title axisTitlePadding opened a gap between the axis title and its tick labels on the left Y axis but not on a right-side (second) Y axis: on the right the padding was spent as dead space on the outer edge of the title, leaving the title jammed against its tick numbers regardless of the padding value. Push the right-side title outward past the tick labels by the padding so it forms a gap mirroring the left side, and shift the bounds origin inward by the same amount so the reserved column width is unchanged. Adds RegressionIssue503Test (asserts the title/tick-label gap scales with padding on both sides via pixel detection) and a TestForIssue503 demo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../standalone/issues/TestForIssue503.java | 57 +++++++ .../xchart/internal/chartpart/AxisTitle.java | 16 +- .../chartpart/RegressionIssue503Test.java | 145 ++++++++++++++++++ 3 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue503.java create mode 100644 xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionIssue503Test.java diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue503.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue503.java new file mode 100644 index 000000000..221939145 --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue503.java @@ -0,0 +1,57 @@ +package org.knowm.xchart.standalone.issues; + +import org.knowm.xchart.SwingWrapper; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.style.Styler; +import org.knowm.xchart.style.theme.XChartTheme; + +/** + * Issue #503 — Axis Title Padding does not apply to the second (right-side) Y-Axis. + * + *

A custom theme (extending {@link org.knowm.xchart.style.theme.AbstractBaseTheme} via {@link + * XChartTheme}) returns a large {@code getAxisTitlePadding()}. On the LEFT axis the padding opens up + * a wide gap between the rotated axis title and the tick numbers. On the RIGHT axis the title stays + * jammed against its tick numbers no matter how big the padding is — the padding is spent as dead + * space on the outer edge of the title instead of between the title and its labels. + * + *

Run {@link #main} and compare the two sides. + */ +public class TestForIssue503 { + + private static final int BIG_PADDING = 60; + + /** Custom theme, as in the bug report: only the axis-title padding is overridden. */ + static class BigTitlePaddingTheme extends XChartTheme { + @Override + public int getAxisTitlePadding() { + return BIG_PADDING; + } + } + + public static XYChart getChart() { + + XYChart chart = new XYChart(900, 500); + chart.setTitle("Issue #503 — axisTitlePadding=" + BIG_PADDING + " (left works, right doesn't)"); + chart.getStyler().setTheme(new BigTitlePaddingTheme()); + + chart.getStyler().setYAxisGroupPosition(0, Styler.YAxisPosition.Left); + chart.getStyler().setYAxisGroupPosition(1, Styler.YAxisPosition.Right); + + chart.setXAxisTitle("x"); + chart.setYAxisGroupTitle(0, "LEFT axis title"); + chart.setYAxisGroupTitle(1, "RIGHT axis title"); + + chart + .addSeries("left series", new double[] {1, 2, 3}, new double[] {10, 20, 30}) + .setYAxisGroup(0); + chart + .addSeries("right series", new double[] {1, 2, 3}, new double[] {1000, 2000, 3000}) + .setYAxisGroup(1); + + return chart; + } + + public static void main(String[] args) { + new SwingWrapper<>(getChart()).displayChart(); + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTitle.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTitle.java index 6f4403f78..769ca1f52 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTitle.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTitle.java @@ -56,14 +56,19 @@ public void paint(Graphics2D g) { // /////////////////////////////////////////////// + int axisTitlePadding = chart.getStyler().getAxisTitlePadding(); boolean onRight = chart.getStyler().getYAxisGroupPosistion(yAxis.getYIndex()) == YAxisPosition.Right; int xOffset; if (onRight) { + // Push the title outward past the tick labels by the padding, so the padding forms a + // gap between the tick labels and the title — mirroring the left side, where the padding + // is baked into the title bounds width that the tick labels are offset by. See issue #503. xOffset = (int) (yAxis.getAxisTick().getBounds().getX() + yAxis.getAxisTick().getBounds().getWidth() + + axisTitlePadding + nonRotatedRectangle.getHeight()); } else { xOffset = (int) (yAxis.getBounds().getX() + nonRotatedRectangle.getHeight()); @@ -101,11 +106,18 @@ public void paint(Graphics2D g) { // System.out.println(nonRotatedRectangle.getHeight()); // bounds + // The bounds start at the inner edge of the title's column. On the right the padding sits + // between the tick labels and the title text, so the origin is padding further inward than + // the text; on the left the padding trails the text and the origin is at the text itself. + double boundsX = + onRight + ? xOffset - nonRotatedRectangle.getHeight() - axisTitlePadding + : xOffset - nonRotatedRectangle.getHeight(); bounds = new Rectangle2D.Double( - xOffset - nonRotatedRectangle.getHeight(), + boundsX, yOffset - nonRotatedRectangle.getWidth(), - nonRotatedRectangle.getHeight() + chart.getStyler().getAxisTitlePadding(), + nonRotatedRectangle.getHeight() + axisTitlePadding, nonRotatedRectangle.getWidth()); // g.setColor(Color.blue); // g.draw(bounds); diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionIssue503Test.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionIssue503Test.java new file mode 100644 index 000000000..e312bda70 --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionIssue503Test.java @@ -0,0 +1,145 @@ +package org.knowm.xchart.internal.chartpart; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.List; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.Test; +import org.knowm.xchart.BitmapEncoder; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; +import org.knowm.xchart.XYSeries; +import org.knowm.xchart.style.Styler.YAxisPosition; + +/** + * Regression test for issue #503: {@code axisTitlePadding} must open up a gap between the axis title + * and its tick labels on BOTH the left and the right Y axis. Before the fix the padding widened the + * left-axis gap but was dumped as dead space on the outer edge of the right-axis title, leaving the + * right title jammed against its tick numbers no matter how big the padding was. + * + *

The title text and tick labels are painted in distinct colors so their painted positions can + * be located directly from the rendered pixels — the axis title bounds origin is pinned to the + * column's inner edge and does not reflect where the rotated text actually lands. + */ +public class RegressionIssue503Test { + + private static final Color TITLE_COLOR = new Color(255, 0, 0); // red + private static final Color TICKS_COLOR = new Color(0, 0, 255); // blue + + private static BufferedImage render(int padding) { + XYChart chart = new XYChartBuilder().width(900).height(500).build(); + chart.getStyler().setAxisTitlePadding(padding); + chart.getStyler().setYAxisGroupPosition(0, YAxisPosition.Left); + chart.getStyler().setYAxisGroupPosition(1, YAxisPosition.Right); + + XYSeries left = chart.addSeries("left", List.of(1, 2, 3), List.of(10, 20, 30)); + left.setYAxisGroup(0); + XYSeries right = chart.addSeries("right", List.of(1, 2, 3), List.of(1000, 2000, 3000)); + right.setYAxisGroup(1); + + // Neutralize series colors so the data lines/markers don't pollute the red/blue pixel search. + chart.getStyler().setLegendVisible(false); + chart.getStyler().setPlotGridLinesVisible(false); + for (XYSeries s : new XYSeries[] {left, right}) { + s.setLineColor(Color.BLACK); + s.setMarker(org.knowm.xchart.style.markers.SeriesMarkers.NONE); + } + + chart.setYAxisGroupTitle(0, "LEFT TITLE"); + chart.setYAxisGroupTitle(1, "RIGHT TITLE"); + + // Color title text and tick labels distinctly so they can be located in the pixels. + chart.getStyler().setYAxisGroupTitleColor(0, TITLE_COLOR); + chart.getStyler().setYAxisGroupTitleColor(1, TITLE_COLOR); + chart.getStyler().setYAxisGroupTickLabelsColorMap(0, TICKS_COLOR); + chart.getStyler().setYAxisGroupTickLabelsColorMap(1, TICKS_COLOR); + + return BitmapEncoder.getBufferedImage(chart); + } + + /** Smallest x containing a pixel of the given color, or -1 if none. */ + private static int minX(BufferedImage img, Color c) { + for (int x = 0; x < img.getWidth(); x++) { + if (columnHasColor(img, x, c)) { + return x; + } + } + return -1; + } + + /** Largest x containing a pixel of the given color, or -1 if none. */ + private static int maxX(BufferedImage img, Color c) { + for (int x = img.getWidth() - 1; x >= 0; x--) { + if (columnHasColor(img, x, c)) { + return x; + } + } + return -1; + } + + private static boolean columnHasColor(BufferedImage img, int x, Color c) { + for (int y = 0; y < img.getHeight(); y++) { + if (isColor(img.getRGB(x, y), c)) { + return true; + } + } + return false; + } + + private static boolean isColor(int rgb, Color c) { + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + // Antialiasing blends toward the background, so match on the dominant channel. + if (c.equals(TITLE_COLOR)) { + return r > 120 && g < 100 && b < 100; + } + return b > 120 && r < 100 && g < 100; + } + + /** + * On the right side (title is outboard of the tick labels) the gap is the distance from the tick + * labels' right edge to the title text's left edge. + */ + private static int rightGap(BufferedImage img) { + int ticksRight = maxX(img, TICKS_COLOR); + // Title text left edge = smallest title x that is to the right of the tick labels. + for (int x = ticksRight + 1; x < img.getWidth(); x++) { + if (columnHasColor(img, x, TITLE_COLOR)) { + return x - ticksRight; + } + } + throw new AssertionError("right axis title text not found"); + } + + /** + * On the left side (title is outboard of the tick labels, i.e. to their left) the gap is the + * distance from the title text's right edge to the tick labels' left edge. + */ + private static int leftGap(BufferedImage img) { + int ticksLeft = minX(img, TICKS_COLOR); + for (int x = ticksLeft - 1; x >= 0; x--) { + if (columnHasColor(img, x, TITLE_COLOR)) { + return ticksLeft - x; + } + } + throw new AssertionError("left axis title text not found"); + } + + @Test + public void rightAxisTitleGapScalesWithPadding() { + int smallGap = rightGap(render(5)); + int largeGap = rightGap(render(105)); + // The gap must grow by ~100 (the padding delta); before the fix it stayed fixed. + assertThat((double) (largeGap - smallGap)).isCloseTo(100.0, Offset.offset(3.0)); + } + + @Test + public void leftAxisTitleGapStillScalesWithPadding() { + int smallGap = leftGap(render(5)); + int largeGap = leftGap(render(105)); + assertThat((double) (largeGap - smallGap)).isCloseTo(100.0, Offset.offset(3.0)); + } +}