diff --git a/README.md b/README.md index 1686a31f..56140d4a 100644 --- a/README.md +++ b/README.md @@ -687,7 +687,7 @@ Add the XChart library as a dependency to your pom.xml file: org.knowm.xchart xchart - 4.0.3 + 4.0.4 ``` @@ -710,7 +710,7 @@ For snapshots, add the following to your pom.xml file: org.knowm.xchart xchart -4.0.4-SNAPSHOT +4.0.5-SNAPSHOT ``` diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java index beecb31d..6c56d18c 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java @@ -105,15 +105,17 @@ else if (direction == Axis_.Direction.X && styler.isXAxisTicksVisible()) { g.setColor(styler.getXAxisTickMarksColor()); int axisTickMarkLength = styler.getAxisTickMarkLength(); double xOffset = chart.getXAxis().getBounds().getX(); - double yOffset = - chart.getXAxis().getAxisTick().getAxisTickLabels().getBounds().getY() - - styler.getAxisTickPadding(); + // anchor the axis line to the top of the x-axis bounds — which sits exactly plotMargin + // below the plot area — rather than deriving it from the tick label geometry, whose + // measured height can drift sub-pixel from the reserved bounds and shift the line + double lineYOffset = chart.getXAxis().getBounds().getY(); + double yOffset = lineYOffset + axisTickMarkLength; // bounds bounds = new Rectangle2D.Double( xOffset, - yOffset - axisTickMarkLength, + lineYOffset, chart.getXAxis().getBounds().getWidth(), axisTickMarkLength); // g.setColor(Color.yellow); @@ -145,11 +147,13 @@ else if (direction == Axis_.Direction.X && styler.isXAxisTicksVisible()) { if (styler.isAxisTicksLineVisible()) { g.setStroke(styler.getAxisTickMarksStroke()); - g.drawLine( - (int) xOffset, - (int) (yOffset - axisTickMarkLength), - (int) (xOffset + chart.getXAxis().getBounds().getWidth()), - (int) (yOffset - axisTickMarkLength)); + Shape line = + new Line2D.Double( + xOffset, + lineYOffset, + xOffset + chart.getXAxis().getBounds().getWidth(), + lineYOffset); + g.draw(line); } } else { bounds = new Rectangle2D.Double(); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java index 8a8151c0..20dd1bee 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java @@ -168,25 +168,17 @@ public void paint(java.awt.Graphics2D g) { double axisTickLabelsHeight = 0.0; if (axesChartStyler.isYAxisTicksVisible()) { - // find the widest label using the actual renderer (TeX or plain text) + // find the widest label using the same metric AxisTickLabels paints with — any mismatch + // between this hint and the painted column shifts the axis line away from its intended + // plotMargin distance to the plot area + FontRenderContext frc = new FontRenderContext(null, true, false); double maxLabelWidth = 0; for (int i = 0; i < axisTickCalculator.getTickLabels().size(); i++) { String lbl = axisTickCalculator.getTickLabels().get(i); if (lbl == null || lbl.isEmpty()) { continue; } - double w; - if (TexRenderer.isTeX(lbl)) { - w = TexRenderer.getBounds(lbl, axesChartStyler.getAxisTickLabelsFont()).getWidth(); - } else { - w = - new TextLayout( - lbl, - axesChartStyler.getAxisTickLabelsFont(), - new FontRenderContext(null, true, false)) - .getBounds() - .getWidth(); - } + double w = TickLabelMetrics.width(lbl, axesChartStyler.getAxisTickLabelsFont(), frc); if (w > maxLabelWidth) { maxLabelWidth = w; } @@ -214,18 +206,7 @@ public void paint(java.awt.Graphics2D g) { if (lbl == null || lbl.isEmpty()) { continue; } - double w; - if (TexRenderer.isTeX(lbl)) { - w = TexRenderer.getBounds(lbl, axesChartStyler.getAxisTickLabelsFont()).getWidth(); - } else { - w = - new TextLayout( - lbl, - axesChartStyler.getAxisTickLabelsFont(), - new FontRenderContext(null, true, false)) - .getBounds() - .getWidth(); - } + double w = TickLabelMetrics.width(lbl, axesChartStyler.getAxisTickLabelsFont(), frc); if (w > slaveMaxWidth) { slaveMaxWidth = w; } diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisLinePlotMarginTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisLinePlotMarginTest.java new file mode 100644 index 00000000..e7a5d557 --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisLinePlotMarginTest.java @@ -0,0 +1,85 @@ +package org.knowm.xchart.internal.chartpart; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; +import org.knowm.xchart.BitmapEncoder; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; + +// The axis tick lines that hug the plot area must sit at the same distance (plotMargin) from the +// plot on both the y-axis and x-axis sides. Historically the y-axis line was positioned from the +// painted tick label width while the plot edge was positioned from the width *hint*; when the two +// measurements diverged (e.g. advance width vs ink width after issue #618), the y-axis line +// drifted toward the plot and the gaps went visibly asymmetric. +// +// Renders to an off-screen image (no XChartPanel / Swing display) so it runs headless on CI. +class AxisLinePlotMarginTest { + + @Test + void axisLinesSitAtPlotMarginOnBothSides() throws Exception { + + XYChart chart = new XYChartBuilder().width(800).height(600).title("t").build(); + chart.addSeries("s", new double[] {1, 2, 3}, new double[] {10, 20, 30}); + chart.getStyler().setLegendVisible(false); + chart.getStyler().setPlotGridLinesVisible(false); + chart.getStyler().setPlotBackgroundColor(Color.YELLOW); + chart.getStyler().setPlotBorderVisible(true); + chart.getStyler().setPlotBorderColor(Color.RED); + + BufferedImage img = BitmapEncoder.getBufferedImage(chart); + + // locate the yellow plot area + int yellow = Color.YELLOW.getRGB(); + int minX = Integer.MAX_VALUE, maxX = -1, minY = Integer.MAX_VALUE, maxY = -1; + for (int y = 0; y < img.getHeight(); y++) { + for (int x = 0; x < img.getWidth(); x++) { + if (img.getRGB(x, y) == yellow) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + } + assertThat(maxX).as("plot area must be found").isGreaterThan(minX); + + // walk left from the plot border to the y-axis line (first non-background pixel) + int borderLeft = minX - 1; + int background = img.getRGB(0, 0); + int midY = (minY + maxY) / 2; + int yAxisLineX = -1; + for (int x = borderLeft - 1; x > borderLeft - 20; x--) { + if (img.getRGB(x, midY) != background) { + yAxisLineX = x; + break; + } + } + assertThat(yAxisLineX).as("y-axis line must be found left of the plot").isPositive(); + + // walk down from the plot border to the x-axis line; use a column 1/3 into the plot to make a + // tick mark collision unlikely (tick marks are only a few px wide, and if one were hit it + // would not change the first dark pixel: marks start at the line and extend away from it) + int borderBottom = maxY + 1; + int midX = minX + (maxX - minX) / 3; + int xAxisLineY = -1; + for (int y = borderBottom + 1; y < borderBottom + 20; y++) { + if (img.getRGB(midX, y) != background) { + xAxisLineY = y; + break; + } + } + assertThat(xAxisLineY).as("x-axis line must be found below the plot").isPositive(); + + int leftGap = borderLeft - yAxisLineX; + int bottomGap = xAxisLineY - borderBottom; + assertThat(leftGap) + .as("y-axis line and x-axis line must sit at the same distance from the plot area") + .isEqualTo(bottomGap); + assertThat(leftGap) + .as("axis line distance must equal the styler's plotMargin") + .isEqualTo(chart.getStyler().getPlotMargin()); + } +}