From 306b07fbd4ac98dd5aa6afdfe0c614c178b8bed6 Mon Sep 17 00:00:00 2001 From: PHJ2000 <105151063+PHJ2000@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:46:08 +0900 Subject: [PATCH 1/2] Allow tick spacing hints per Y-axis group --- README.md | 13 ++ .../standalone/issues/TestForIssue797.java | 36 +++++ .../chartpart/AxisTickCalculator_.java | 58 ++++--- .../AxisTickCalculator_Callback.java | 27 +++- .../chartpart/AxisTickCalculator_Number.java | 15 +- .../xchart/internal/chartpart/Axis_Y.java | 15 +- .../knowm/xchart/style/AxesChartStyler.java | 70 +++++++-- .../chartpart/YAxisGroupTickSpacingTest.java | 144 ++++++++++++++++++ 8 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java create mode 100644 xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java diff --git a/README.md b/README.md index 56140d4a8..eebe54834 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,19 @@ chart.getStyler().setyAxisTickLabelsFormattingFunction(x ->NumberWordConverter.c ### Multiple Axes +Linear numeric Y-axis groups can use different tick spacing hints (in pixels): + +```java +chart.getStyler().setYAxisTickMarkSpacingHint(40); // default for all groups +chart.getStyler().setYAxisTickMarkSpacingHint(1, 120); // wider spacing for group 1 +chart.getStyler().setYAxisTickMarkSpacingHint(1, null); // restore the default +``` + +A group override also applies to equally spaced data and custom tick label formatters. +It is a pixel hint, not a fixed interval in data units. Logarithmic and categorical axes +keep their existing tick rules; merged axes follow the master group's ticks. + + XChart has multiple y axes feature. Y offset is calculated according to the Y-Axis the series configured. Max `y` value in this axis is calculated according to the series on this axis only. To set the y group: diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java new file mode 100644 index 000000000..bf4c02e10 --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java @@ -0,0 +1,36 @@ +package org.knowm.xchart.standalone.issues; + +import org.knowm.xchart.SwingWrapper; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; +import org.knowm.xchart.style.Styler; + +/** Demonstrates independent tick spacing for two Y-axis groups. */ +public class TestForIssue797 { + + public static void main(String[] args) { + new SwingWrapper<>(getChart()).displayChart(); + } + + /** Constructs the chart without opening a window. */ + public static XYChart getChart() { + XYChart chart = + new XYChartBuilder() + .width(900) + .height(600) + .title("Y-axis group tick spacing") + .xAxisTitle("Sample") + .yAxisTitle("Measurement") + .build(); + double[] x = {0, 1, 2, 3, 4, 5}; + chart.addSeries("Measurement", x, new double[] {0, 20, 40, 60, 80, 100}); + chart.addSeries("Enabled", x, new double[] {0, 0, 1, 1, 0, 1}).setYAxisGroup(1); + chart.setYAxisGroupTitle(1, "Enabled"); + chart.getStyler().setYAxisGroupPosition(1, Styler.YAxisPosition.Right); + chart.getStyler().setYAxisMin(1, 0.0); + chart.getStyler().setYAxisMax(1, 1.0); + chart.getStyler().setYAxisTickMarkSpacingHint(40); + chart.getStyler().setYAxisTickMarkSpacingHint(1, 400); + return chart; + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java index 9aa9b64c1..67acf0293 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java @@ -1,6 +1,7 @@ package org.knowm.xchart.internal.chartpart; -import java.awt.*; +import java.awt.Font; +import java.awt.Shape; import java.awt.font.FontRenderContext; import java.awt.font.TextLayout; import java.awt.geom.AffineTransform; @@ -8,9 +9,12 @@ import java.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; -import java.text.*; -import java.util.*; +import java.text.Format; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.knowm.xchart.internal.Utils; @@ -39,6 +43,8 @@ public abstract class AxisTickCalculator_ implements AxisTickCalculator { Format axisFormat; + int yIndex; + private static final int MAX_LABEL_LENGTH = 20; /** @@ -157,14 +163,13 @@ boolean willLabelsFitInTickSpaceHint(List tickLabels, int tickSpacingHin AffineTransform rot = null; if (styler.getXAxisLabelRotation() != 0) { - rot = AffineTransform.getRotateInstance( - -1 * Math.toRadians(styler.getXAxisLabelRotation())); + rot = AffineTransform.getRotateInstance(-1 * Math.toRadians(styler.getXAxisLabelRotation())); } Shape shape = textLayout.getOutline(rot); Rectangle2D rectangle = shape.getBounds(); - double largestLabelWidth = Direction.X.equals(this.axisDirection) - ? rectangle.getWidth() : rectangle.getHeight(); + double largestLabelWidth = + Direction.X.equals(this.axisDirection) ? rectangle.getWidth() : rectangle.getHeight(); return (largestLabelWidth * 1.1 < tickSpacingHint); } @@ -174,6 +179,18 @@ public Format getAxisFormat() { return axisFormat; } + private boolean hasYAxisGroupSpacingHint() { + return axisDirection == Direction.Y && styler.getYAxisTickMarkSpacingHint(yIndex) != null; + } + + private int getTickMarkSpacingHint() { + if (axisDirection == Direction.X) { + return styler.getXAxisTickMarkSpacingHint(); + } + Integer groupHint = styler.getYAxisTickMarkSpacingHint(yIndex); + return groupHint != null ? groupHint : styler.getYAxisTickMarkSpacingHint(); + } + protected void calculate() { // System.out.println("calculate"); @@ -199,7 +216,7 @@ protected void calculate() { if (axisDirection == Direction.X && tickSpace < styler.getXAxisTickMarkSpacingHint()) { return; } - if (axisDirection == Direction.Y && tickSpace < styler.getYAxisTickMarkSpacingHint()) { + if (axisDirection == Direction.Y && tickSpace < getTickMarkSpacingHint()) { return; } @@ -219,21 +236,17 @@ protected void calculate() { // the span of the data double span = Math.abs(Math.min((maxValue - minValue), Double.MAX_VALUE - 1)); // in data space - if (axisValues != null && areValuesEquallySpaced(axisValues)) { + if (axisValues != null && areValuesEquallySpaced(axisValues) && !hasYAxisGroupSpacingHint()) { calculateForEquallySpacedAxisValues(tickSpace, margin); return; } ////////////////////////// - int tickSpacingHint = - (axisDirection == Direction.X - ? styler.getXAxisTickMarkSpacingHint() - : styler.getYAxisTickMarkSpacingHint()) - - 5; + int tickSpacingHint = getTickMarkSpacingHint() - 5; // for very short plots, squeeze some more ticks in than normal into the Y-Axis - if (axisDirection == Direction.Y && tickSpace < 160) { + if (axisDirection == Direction.Y && tickSpace < 160 && !hasYAxisGroupSpacingHint()) { tickSpacingHint = 25 - 5; } @@ -369,12 +382,16 @@ protected void calculate() { // Prune ticks that fall outside the actual data band [minValue, maxValue]. The generation loop // above intentionally overshoots by up to a grid step on each end (getFirstPosition starts one - // grid step below minValue and the loop bound runs two grid steps past maxValue); those overshoot - // ticks must not become visible labels. Gridlines and tick marks already clip to the plot bounds, - // but the tick-label renderer clips to the taller axis-column bounds, so an out-of-range label -- + // grid step below minValue and the loop bound runs two grid steps past maxValue); those + // overshoot + // ticks must not become visible labels. Gridlines and tick marks already clip to the plot + // bounds, + // but the tick-label renderer clips to the taller axis-column bounds, so an out-of-range label + // -- // e.g. a negative label when all data is positive -- can leak into the plot margin. Issue #634. // A tick value v maps to pixel margin + (v - minValue) / (maxValue - minValue) * tickSpace, so - // v is within [minValue, maxValue] exactly when its pixel is within [margin, margin + tickSpace]. + // v is within [minValue, maxValue] exactly when its pixel is within [margin, margin + + // tickSpace]. double bandLow = margin - 1e-6; double bandHigh = margin + tickSpace + 1e-6; List keptLabels = new ArrayList<>(tickLabels.size()); @@ -386,7 +403,8 @@ protected void calculate() { keptLocations.add(loc); } } - // Only apply the pruning if at least one tick survives, so a degenerate range can never blank the + // Only apply the pruning if at least one tick survives, so a degenerate range can never blank + // the // axis entirely (falls back to the pre-prune behavior). if (!keptLocations.isEmpty() && keptLocations.size() < tickLocations.size()) { tickLabels.clear(); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Callback.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Callback.java index 30c83b0df..77fffce58 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Callback.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Callback.java @@ -2,7 +2,6 @@ import java.util.List; import java.util.function.Function; - import org.knowm.xchart.internal.chartpart.Axis_.Direction; import org.knowm.xchart.style.AxesChartStyler; @@ -29,7 +28,19 @@ public AxisTickCalculator_Callback( double maxValue, AxesChartStyler styler) { + this(formattingCallback, axisDirection, workingSpace, minValue, maxValue, styler, 0); + } + + AxisTickCalculator_Callback( + Function formattingCallback, + Direction axisDirection, + double workingSpace, + double minValue, + double maxValue, + AxesChartStyler styler, + int yIndex) { super(axisDirection, workingSpace, minValue, maxValue, styler); + this.yIndex = yIndex; axisFormat = new Formatter_Custom(formattingCallback); calculate(); } @@ -42,7 +53,21 @@ public AxisTickCalculator_Callback( double maxValue, List axisValues, AxesChartStyler styler) { + this( + formattingCallback, axisDirection, workingSpace, minValue, maxValue, axisValues, styler, 0); + } + + AxisTickCalculator_Callback( + Function formattingCallback, + Direction axisDirection, + double workingSpace, + double minValue, + double maxValue, + List axisValues, + AxesChartStyler styler, + int yIndex) { super(axisDirection, workingSpace, minValue, maxValue, axisValues, styler); + this.yIndex = yIndex; axisFormat = new Formatter_Custom(formattingCallback); calculate(); } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Number.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Number.java index 7c99b8214..5dfbf326e 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Number.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Number.java @@ -41,8 +41,20 @@ public AxisTickCalculator_Number( double maxValue, List axisValues, AxesChartStyler styler) { + this(axisDirection, workingSpace, minValue, maxValue, axisValues, styler, 0); + } + + AxisTickCalculator_Number( + Direction axisDirection, + double workingSpace, + double minValue, + double maxValue, + List axisValues, + AxesChartStyler styler, + int yIndex) { super(axisDirection, workingSpace, minValue, maxValue, axisValues, styler); - formatterNumber = new Formatter_Number(styler, axisDirection, minValue, maxValue); + this.yIndex = yIndex; + formatterNumber = new Formatter_Number(styler, axisDirection, minValue, maxValue, yIndex); axisFormat = formatterNumber; calculate(); } @@ -66,6 +78,7 @@ public AxisTickCalculator_Number( int yIndex) { super(axisDirection, workingSpace, minValue, maxValue, styler); + this.yIndex = yIndex; formatterNumber = new Formatter_Number(styler, axisDirection, minValue, maxValue, yIndex); axisFormat = formatterNumber; calculate(); 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 20dd1bee6..2469339cc 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 @@ -13,13 +13,9 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; - -import org.knowm.xchart.CategoryChart; import org.knowm.xchart.CategorySeries; import org.knowm.xchart.HeatMapChart; -import org.knowm.xchart.HorizontalBarChart; import org.knowm.xchart.HorizontalBarSeries; -import org.knowm.xchart.XYChart; import org.knowm.xchart.XYSeries; import org.knowm.xchart.internal.series.AxesChartSeries; import org.knowm.xchart.internal.series.Series.DataType; @@ -306,7 +302,8 @@ && getDataType() != DataType.Date) { min, max, yData, - axesChartStyler); + axesChartStyler, + getYIndex()); } return new AxisTickCalculator_Callback( axesChartStyler.getYAxisTickLabelsFormattingFunction(), @@ -314,7 +311,8 @@ && getDataType() != DataType.Date) { workingSpace, min, max, - axesChartStyler); + axesChartStyler, + getYIndex()); } else if (axesChartStyler.isYAxisLogarithmic() && getDataType() != DataType.Date) { @@ -342,7 +340,7 @@ && getDataType() != DataType.Date) { } else { if (!yData.isEmpty()) { return new AxisTickCalculator_Number( - Axis_.Direction.Y, workingSpace, min, max, yData, axesChartStyler); + Axis_.Direction.Y, workingSpace, min, max, yData, axesChartStyler, getYIndex()); } return new AxisTickCalculator_Number( Axis_.Direction.Y, workingSpace, min, max, axesChartStyler, getYIndex()); @@ -438,8 +436,7 @@ public double getChartValue(double screenPoint) { return minVal; } - double margin = - org.knowm.xchart.internal.Utils.getTickStartOffset(workingSpace, tickSpace); + double margin = org.knowm.xchart.internal.Utils.getTickStartOffset(workingSpace, tickSpace); minVal = isLog ? Math.log10(minVal) : minVal; maxVal = isLog ? Math.log10(maxVal) : maxVal; diff --git a/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java b/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java index c641d80bb..ed6eaff07 100644 --- a/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java +++ b/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java @@ -30,6 +30,7 @@ public abstract class AxesChartStyler extends Styler { private int axisTitlePadding; private int xAxisTickMarkSpacingHint; private int yAxisTickMarkSpacingHint; + private Map yAxisTickMarkSpacingHintMap; private boolean isXAxisLogarithmic; private boolean isYAxisLogarithmic; private Double xAxisMin; @@ -79,7 +80,9 @@ public abstract class AxesChartStyler extends Styler { */ private final HashMap yAxisGroupMergeMap = new HashMap<>(); - /** @see #setMergedAxisColocateSlaveLabels */ + /** + * @see #setMergedAxisColocateSlaveLabels + */ private boolean mergedAxisColocateSlaveLabels = false; /** @@ -89,6 +92,7 @@ public abstract class AxesChartStyler extends Styler { * @see #setMergedAxisColocatedSlaveLabelsGap */ private double mergedAxisColocatedSlaveLabelsGap = 6.0; + private TextAlignment xAxisLabelAlignment = TextAlignment.Centre; private TextAlignment xAxisLabelAlignmentVertical = TextAlignment.Centre; private TextAlignment yAxisLabelAlignment = TextAlignment.Left; @@ -117,6 +121,7 @@ void setAllStyles() { this.axisTitlePadding = theme.getAxisTitlePadding(); this.xAxisTickMarkSpacingHint = theme.getXAxisTickMarkSpacingHint(); this.yAxisTickMarkSpacingHint = theme.getYAxisTickMarkSpacingHint(); + this.yAxisTickMarkSpacingHintMap = new HashMap<>(); this.isXAxisLogarithmic = false; this.isYAxisLogarithmic = false; this.xAxisMin = null; @@ -446,6 +451,43 @@ public AxesChartStyler setYAxisTickMarkSpacingHint(int yAxisTickMarkSpacingHint) return this; } + /** + * Returns the spacing hint configured for a Y-axis group, or {@code null} if it uses the global + * hint. + * + * @param yAxisGroup the Y-axis group + * @return the group-specific hint in pixels, or {@code null} + */ + public Integer getYAxisTickMarkSpacingHint(int yAxisGroup) { + + return yAxisTickMarkSpacingHintMap.get(yAxisGroup); + } + + /** + * Sets a tick spacing hint in pixels for a linear numeric Y-axis group. This overrides the global + * hint for that group, including when its data values are equally spaced. Tick positions are + * calculated automatically; this does not set an interval in data units. Merged axes use the + * master group's tick positions. + * + * @param yAxisGroup the Y-axis group + * @param yAxisTickMarkSpacingHint the non-negative spacing hint, or {@code null} to use the + * global hint again + * @return this styler + */ + public AxesChartStyler setYAxisTickMarkSpacingHint( + int yAxisGroup, Integer yAxisTickMarkSpacingHint) { + + if (yAxisTickMarkSpacingHint == null) { + yAxisTickMarkSpacingHintMap.remove(yAxisGroup); + } else { + if (yAxisTickMarkSpacingHint < 0) { + throw new IllegalArgumentException("yAxisTickMarkSpacingHint cannot be less than 0 !!!"); + } + yAxisTickMarkSpacingHintMap.put(yAxisGroup, yAxisTickMarkSpacingHint); + } + return this; + } + public boolean isXAxisLogarithmic() { return isXAxisLogarithmic; @@ -811,13 +853,17 @@ public AxesChartStyler setXAxisTickLabelsFormattingFunction( return this; } - /** @deprecated Use {@link #getXAxisTickLabelsFormattingFunction()} instead. */ + /** + * @deprecated Use {@link #getXAxisTickLabelsFormattingFunction()} instead. + */ @Deprecated public Function getxAxisTickLabelsFormattingFunction() { return getXAxisTickLabelsFormattingFunction(); } - /** @deprecated Use {@link #setXAxisTickLabelsFormattingFunction(Function)} instead. */ + /** + * @deprecated Use {@link #setXAxisTickLabelsFormattingFunction(Function)} instead. + */ @Deprecated public AxesChartStyler setxAxisTickLabelsFormattingFunction( Function xAxisTickLabelsFormattingFunction) { @@ -842,13 +888,17 @@ public AxesChartStyler setYAxisTickLabelsFormattingFunction( return this; } - /** @deprecated Use {@link #getYAxisTickLabelsFormattingFunction()} instead. */ + /** + * @deprecated Use {@link #getYAxisTickLabelsFormattingFunction()} instead. + */ @Deprecated public Function getyAxisTickLabelsFormattingFunction() { return getYAxisTickLabelsFormattingFunction(); } - /** @deprecated Use {@link #setYAxisTickLabelsFormattingFunction(Function)} instead. */ + /** + * @deprecated Use {@link #setYAxisTickLabelsFormattingFunction(Function)} instead. + */ @Deprecated public AxesChartStyler setyAxisTickLabelsFormattingFunction( Function yAxisTickLabelsFormattingFunction) { @@ -950,9 +1000,9 @@ public AxesChartStyler setYAxisGroupTickMarksColorMap(int yAxisGroup, Color yAxi * Merge multiple Y-axis groups onto a single shared visual axis line per side. All specified * logical group indices will share the axis line of the lowest-numbered index in the set. * - *

Example: {@code styler.mergeYAxisGroups(0, 1)} causes group 1 to share group 0's axis - * line. Group 0 acts as master (drives gridline positions); group 1 is a slave (translates - * master tick pixels into its own value labels). + *

Example: {@code styler.mergeYAxisGroups(0, 1)} causes group 1 to share group 0's axis line. + * Group 0 acts as master (drives gridline positions); group 1 is a slave (translates master tick + * pixels into its own value labels). * *

This is opt-in: charts that do not call this method behave exactly as before. * @@ -998,8 +1048,8 @@ public Map getYAxisGroupMergeMap() { /** * When {@code true}, slave Y-axis tick labels are rendered stacked below the master's labels on - * the shared axis column instead of appearing as a separate axis column. Colors are controlled - * by {@link AxesChartStyler#setYAxisGroupTickLabelsColorMap}. Default: {@code false}. + * the shared axis column instead of appearing as a separate axis column. Colors are controlled by + * {@link AxesChartStyler#setYAxisGroupTickLabelsColorMap}. Default: {@code false}. */ public AxesChartStyler setMergedAxisColocateSlaveLabels(boolean val) { this.mergedAxisColocateSlaveLabels = val; diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java new file mode 100644 index 000000000..8f0ab6cff --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java @@ -0,0 +1,144 @@ +package org.knowm.xchart.internal.chartpart; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.knowm.xchart.CategoryChart; +import org.knowm.xchart.CategoryChartBuilder; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; +import org.knowm.xchart.style.AxesChartStyler; +import org.knowm.xchart.style.XYStyler; + +class YAxisGroupTickSpacingTest { + + @Test + void groupOverridesAreIndependentAndCanBeRemoved() { + AxesChartStyler styler = new XYStyler(); + styler.setYAxisTickMarkSpacingHint(40); + assertThat(styler.setYAxisTickMarkSpacingHint(1, 120)).isSameAs(styler); + assertThat(styler.getYAxisTickMarkSpacingHint(1)).isEqualTo(120); + assertThat(styler.getYAxisTickMarkSpacingHint(0)).isNull(); + assertThat(styler.getYAxisTickMarkSpacingHint()).isEqualTo(40); + styler.setYAxisTickMarkSpacingHint(60); + assertThat(styler.getYAxisTickMarkSpacingHint(1)).isEqualTo(120); + styler.setYAxisTickMarkSpacingHint(1, null); + assertThat(styler.getYAxisTickMarkSpacingHint(1)).isNull(); + assertThat(styler.getYAxisTickMarkSpacingHint()).isEqualTo(60); + styler.setYAxisTickMarkSpacingHint(1, 0); + assertThat(styler.getYAxisTickMarkSpacingHint(1)).isZero(); + assertThatThrownBy(() -> styler.setYAxisTickMarkSpacingHint(1, -1)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(styler.getYAxisTickMarkSpacingHint(1)).isZero(); + } + + @Test + void renderedGroupsHaveIndependentSpacingAndRestoreTheirOriginalTicks() { + XYChart chart = chart(); + paint(chart); + List primary = ticks(chart, 0); + List secondary = ticks(chart, 1); + chart.getStyler().setYAxisTickMarkSpacingHint(1, 180); + paint(chart); + assertThat(ticks(chart, 0)).isEqualTo(primary); + assertThat(ticks(chart, 1).size()).isLessThan(secondary.size()); + chart.getStyler().setYAxisTickMarkSpacingHint(1, null); + paint(chart); + assertThat(ticks(chart, 1)).isEqualTo(secondary); + } + + @Test + void customFormattingKeepsGroupSpecificSpacing() { + XYChart chart = chart(); + chart.getStyler().setYAxisTickLabelsFormattingFunction(value -> "value " + value); + chart.getStyler().setYAxisTickMarkSpacingHint(0, 35); + chart.getStyler().setYAxisTickMarkSpacingHint(1, 180); + paint(chart); + assertThat(ticks(chart, 1).size()).isLessThan(ticks(chart, 0).size()); + assertThat(chart.axisPair.getYAxis(1).getAxisTickCalculator().getTickLabels()) + .allMatch(label -> label.startsWith("value ")); + } + + @Test + void categoryChartUsesNumericYAxisGroupHints() { + CategoryChart chart = new CategoryChartBuilder().width(800).height(600).build(); + List categories = Arrays.asList("A", "B", "C", "D", "E", "F"); + List values = Arrays.asList(0, 20, 40, 60, 80, 100); + chart.addSeries("first", categories, values); + chart.addSeries("second", categories, values).setYAxisGroup(1); + chart.getStyler().setYAxisTickMarkSpacingHint(0, 35); + chart.getStyler().setYAxisTickMarkSpacingHint(1, 180); + paint(chart); + assertThat(chart.axisPair.getYAxis(1).getAxisTickCalculator().getTickLocations().size()) + .isLessThan(chart.axisPair.getYAxis(0).getAxisTickCalculator().getTickLocations().size()); + } + + @Test + void mergedAxesFollowTheMasterSpacingHint() { + XYChart chart = chart(); + chart.getStyler().mergeYAxisGroups(0, 1); + chart.getStyler().setYAxisTickMarkSpacingHint(0, 180); + chart.getStyler().setYAxisTickMarkSpacingHint(1, 35); + paint(chart); + assertThat(ticks(chart, 1)).isEqualTo(ticks(chart, 0)); + } + + @Test + void smallPlotsKeepExplicitGroupHints() { + XYStyler styler = new XYStyler(); + styler.setPlotContentSize(1.0); + styler.setYAxisTickMarkSpacingHint(0, 100); + AxisTickCalculator_Number wide = + new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 100, styler, 0); + styler.setYAxisTickMarkSpacingHint(0, 30); + AxisTickCalculator_Number narrow = + new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 100, styler, 0); + assertThat(wide.getTickLocations().size()).isLessThan(narrow.getTickLocations().size()); + styler.setYAxisTickMarkSpacingHint(0, 200); + assertThat( + new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 100, styler, 0) + .getTickLocations()) + .isEmpty(); + } + + @Test + void xAxisDoesNotUseYAxisGroupHints() { + XYStyler styler = new XYStyler(); + AxisTickCalculator_Number before = + new AxisTickCalculator_Number(Axis_.Direction.X, 800, 0, 100, styler); + styler.setYAxisTickMarkSpacingHint(0, 500); + AxisTickCalculator_Number after = + new AxisTickCalculator_Number(Axis_.Direction.X, 800, 0, 100, styler); + assertThat(after.getTickLocations()).isEqualTo(before.getTickLocations()); + } + + private XYChart chart() { + XYChart chart = new XYChartBuilder().width(800).height(600).build(); + double[] x = {0, 1, 2, 3, 4, 5}; + double[] y = {0, 20, 40, 60, 80, 100}; + chart.addSeries("first", x, y); + chart.addSeries("second", x, y).setYAxisGroup(1); + return chart; + } + + private List ticks(XYChart chart, int group) { + return new ArrayList<>( + chart.axisPair.getYAxis(group).getAxisTickCalculator().getTickLocations()); + } + + private void paint(Chart chart) { + BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + chart.paint(graphics, 800, 600); + } finally { + graphics.dispose(); + } + } +} From b60061c7549a447ba4eab2e65765c8aa5c5d6e5c Mon Sep 17 00:00:00 2001 From: PHJ2000 <105151063+PHJ2000@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:33 +0900 Subject: [PATCH 2/2] Keep Y-axis group ticks visible when resizing --- README.md | 7 +- .../standalone/issues/TestForIssue797.java | 2 +- .../chartpart/AxisTickCalculator_.java | 42 ++++---- .../knowm/xchart/style/AxesChartStyler.java | 8 +- .../chartpart/YAxisGroupTickSpacingTest.java | 97 ++++++++++++++----- 5 files changed, 104 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index eebe54834..fda8f1f05 100644 --- a/README.md +++ b/README.md @@ -444,11 +444,14 @@ Linear numeric Y-axis groups can use different tick spacing hints (in pixels): ```java chart.getStyler().setYAxisTickMarkSpacingHint(40); // default for all groups -chart.getStyler().setYAxisTickMarkSpacingHint(1, 120); // wider spacing for group 1 -chart.getStyler().setYAxisTickMarkSpacingHint(1, null); // restore the default +chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 120); // wider spacing for group 1 +chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, null); // restore the default ``` A group override also applies to equally spaced data and custom tick label formatters. +It takes precedence over the short-plot squeeze. If the hint exceeds the available tick +space, the minimum and maximum ticks are retained when there is positive space and +their formatted labels differ. It is a pixel hint, not a fixed interval in data units. Logarithmic and categorical axes keep their existing tick rules; merged axes follow the master group's ticks. diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java index bf4c02e10..8a15ac098 100644 --- a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue797.java @@ -30,7 +30,7 @@ public static XYChart getChart() { chart.getStyler().setYAxisMin(1, 0.0); chart.getStyler().setYAxisMax(1, 1.0); chart.getStyler().setYAxisTickMarkSpacingHint(40); - chart.getStyler().setYAxisTickMarkSpacingHint(1, 400); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 200); return chart; } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java index 67acf0293..4d10d7995 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_.java @@ -180,14 +180,14 @@ public Format getAxisFormat() { } private boolean hasYAxisGroupSpacingHint() { - return axisDirection == Direction.Y && styler.getYAxisTickMarkSpacingHint(yIndex) != null; + return axisDirection == Direction.Y && styler.getYAxisGroupTickMarkSpacingHint(yIndex) != null; } private int getTickMarkSpacingHint() { if (axisDirection == Direction.X) { return styler.getXAxisTickMarkSpacingHint(); } - Integer groupHint = styler.getYAxisTickMarkSpacingHint(yIndex); + Integer groupHint = styler.getYAxisGroupTickMarkSpacingHint(yIndex); return groupHint != null ? groupHint : styler.getYAxisTickMarkSpacingHint(); } @@ -217,6 +217,16 @@ protected void calculate() { return; } if (axisDirection == Direction.Y && tickSpace < getTickMarkSpacingHint()) { + // An explicit group hint requests sparse ticks, not an empty axis after resizing. + if (hasYAxisGroupSpacingHint() + && tickSpace > 0 + && !isNumberFormatChoppingDecimals(maxValue, minValue)) { + double margin = Utils.getTickStartOffset(workingSpace, tickSpace); + tickLabels.add(getAxisFormat().format(minValue)); + tickLocations.add(margin); + tickLabels.add(getAxisFormat().format(maxValue)); + tickLocations.add(margin + tickSpace); + } return; } @@ -229,13 +239,11 @@ protected void calculate() { } // where the tick should begin in the working space in pixels - double margin = - Utils.getTickStartOffset( - workingSpace, - tickSpace); // in plot space double gridStep = getGridStepForDecimal(tickSpace); + double margin = Utils.getTickStartOffset(workingSpace, tickSpace); // in plot space // the span of the data double span = Math.abs(Math.min((maxValue - minValue), Double.MAX_VALUE - 1)); // in data space + // Explicit group spacing takes precedence over data-driven, equally spaced ticks. if (axisValues != null && areValuesEquallySpaced(axisValues) && !hasYAxisGroupSpacingHint()) { calculateForEquallySpacedAxisValues(tickSpace, margin); return; @@ -245,7 +253,7 @@ protected void calculate() { int tickSpacingHint = getTickMarkSpacingHint() - 5; - // for very short plots, squeeze some more ticks in than normal into the Y-Axis + // Squeeze more ticks into short Y-axes unless the caller explicitly set group spacing. if (axisDirection == Direction.Y && tickSpace < 160 && !hasYAxisGroupSpacingHint()) { tickSpacingHint = 25 - 5; } @@ -380,18 +388,10 @@ protected void calculate() { } while (!areAllTickLabelsUnique(tickLabels) || !willLabelsFitInTickSpaceHint(tickLabels, gridStepInChartSpace)); - // Prune ticks that fall outside the actual data band [minValue, maxValue]. The generation loop - // above intentionally overshoots by up to a grid step on each end (getFirstPosition starts one - // grid step below minValue and the loop bound runs two grid steps past maxValue); those - // overshoot - // ticks must not become visible labels. Gridlines and tick marks already clip to the plot - // bounds, - // but the tick-label renderer clips to the taller axis-column bounds, so an out-of-range label - // -- - // e.g. a negative label when all data is positive -- can leak into the plot margin. Issue #634. - // A tick value v maps to pixel margin + (v - minValue) / (maxValue - minValue) * tickSpace, so - // v is within [minValue, maxValue] exactly when its pixel is within [margin, margin + - // tickSpace]. + // Prune ticks outside [minValue, maxValue]. Generation intentionally overshoots the + // data range by a grid step. Tick marks and gridlines clip to the plot, but labels clip + // to the taller axis column and can leak into the margin (issue #634). + // Data values in this range map to pixels in [margin, margin + tickSpace]. double bandLow = margin - 1e-6; double bandHigh = margin + tickSpace + 1e-6; List keptLabels = new ArrayList<>(tickLabels.size()); @@ -403,9 +403,7 @@ protected void calculate() { keptLocations.add(loc); } } - // Only apply the pruning if at least one tick survives, so a degenerate range can never blank - // the - // axis entirely (falls back to the pre-prune behavior). + // Keep the original ticks if pruning a degenerate range would empty the axis. if (!keptLocations.isEmpty() && keptLocations.size() < tickLocations.size()) { tickLabels.clear(); tickLabels.addAll(keptLabels); diff --git a/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java b/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java index ed6eaff07..476aaf44b 100644 --- a/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java +++ b/xchart/src/main/java/org/knowm/xchart/style/AxesChartStyler.java @@ -458,7 +458,7 @@ public AxesChartStyler setYAxisTickMarkSpacingHint(int yAxisTickMarkSpacingHint) * @param yAxisGroup the Y-axis group * @return the group-specific hint in pixels, or {@code null} */ - public Integer getYAxisTickMarkSpacingHint(int yAxisGroup) { + public Integer getYAxisGroupTickMarkSpacingHint(int yAxisGroup) { return yAxisTickMarkSpacingHintMap.get(yAxisGroup); } @@ -467,14 +467,16 @@ public Integer getYAxisTickMarkSpacingHint(int yAxisGroup) { * Sets a tick spacing hint in pixels for a linear numeric Y-axis group. This overrides the global * hint for that group, including when its data values are equally spaced. Tick positions are * calculated automatically; this does not set an interval in data units. Merged axes use the - * master group's tick positions. + * master group's tick positions. Explicit group hints take precedence over the short-plot + * squeeze. When a hint exceeds the available space, the axis retains its minimum and maximum + * ticks if their formatted labels differ and positive tick space remains. * * @param yAxisGroup the Y-axis group * @param yAxisTickMarkSpacingHint the non-negative spacing hint, or {@code null} to use the * global hint again * @return this styler */ - public AxesChartStyler setYAxisTickMarkSpacingHint( + public AxesChartStyler setYAxisGroupTickMarkSpacingHint( int yAxisGroup, Integer yAxisTickMarkSpacingHint) { if (yAxisTickMarkSpacingHint == null) { diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java index 8f0ab6cff..fdc982bd5 100644 --- a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/YAxisGroupTickSpacingTest.java @@ -22,20 +22,20 @@ class YAxisGroupTickSpacingTest { void groupOverridesAreIndependentAndCanBeRemoved() { AxesChartStyler styler = new XYStyler(); styler.setYAxisTickMarkSpacingHint(40); - assertThat(styler.setYAxisTickMarkSpacingHint(1, 120)).isSameAs(styler); - assertThat(styler.getYAxisTickMarkSpacingHint(1)).isEqualTo(120); - assertThat(styler.getYAxisTickMarkSpacingHint(0)).isNull(); + assertThat(styler.setYAxisGroupTickMarkSpacingHint(1, 120)).isSameAs(styler); + assertThat(styler.getYAxisGroupTickMarkSpacingHint(1)).isEqualTo(120); + assertThat(styler.getYAxisGroupTickMarkSpacingHint(0)).isNull(); assertThat(styler.getYAxisTickMarkSpacingHint()).isEqualTo(40); styler.setYAxisTickMarkSpacingHint(60); - assertThat(styler.getYAxisTickMarkSpacingHint(1)).isEqualTo(120); - styler.setYAxisTickMarkSpacingHint(1, null); - assertThat(styler.getYAxisTickMarkSpacingHint(1)).isNull(); + assertThat(styler.getYAxisGroupTickMarkSpacingHint(1)).isEqualTo(120); + styler.setYAxisGroupTickMarkSpacingHint(1, null); + assertThat(styler.getYAxisGroupTickMarkSpacingHint(1)).isNull(); assertThat(styler.getYAxisTickMarkSpacingHint()).isEqualTo(60); - styler.setYAxisTickMarkSpacingHint(1, 0); - assertThat(styler.getYAxisTickMarkSpacingHint(1)).isZero(); - assertThatThrownBy(() -> styler.setYAxisTickMarkSpacingHint(1, -1)) + styler.setYAxisGroupTickMarkSpacingHint(1, 0); + assertThat(styler.getYAxisGroupTickMarkSpacingHint(1)).isZero(); + assertThatThrownBy(() -> styler.setYAxisGroupTickMarkSpacingHint(1, -1)) .isInstanceOf(IllegalArgumentException.class); - assertThat(styler.getYAxisTickMarkSpacingHint(1)).isZero(); + assertThat(styler.getYAxisGroupTickMarkSpacingHint(1)).isZero(); } @Test @@ -44,11 +44,11 @@ void renderedGroupsHaveIndependentSpacingAndRestoreTheirOriginalTicks() { paint(chart); List primary = ticks(chart, 0); List secondary = ticks(chart, 1); - chart.getStyler().setYAxisTickMarkSpacingHint(1, 180); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 180); paint(chart); assertThat(ticks(chart, 0)).isEqualTo(primary); assertThat(ticks(chart, 1).size()).isLessThan(secondary.size()); - chart.getStyler().setYAxisTickMarkSpacingHint(1, null); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, null); paint(chart); assertThat(ticks(chart, 1)).isEqualTo(secondary); } @@ -57,8 +57,8 @@ void renderedGroupsHaveIndependentSpacingAndRestoreTheirOriginalTicks() { void customFormattingKeepsGroupSpecificSpacing() { XYChart chart = chart(); chart.getStyler().setYAxisTickLabelsFormattingFunction(value -> "value " + value); - chart.getStyler().setYAxisTickMarkSpacingHint(0, 35); - chart.getStyler().setYAxisTickMarkSpacingHint(1, 180); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(0, 35); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 180); paint(chart); assertThat(ticks(chart, 1).size()).isLessThan(ticks(chart, 0).size()); assertThat(chart.axisPair.getYAxis(1).getAxisTickCalculator().getTickLabels()) @@ -72,8 +72,8 @@ void categoryChartUsesNumericYAxisGroupHints() { List values = Arrays.asList(0, 20, 40, 60, 80, 100); chart.addSeries("first", categories, values); chart.addSeries("second", categories, values).setYAxisGroup(1); - chart.getStyler().setYAxisTickMarkSpacingHint(0, 35); - chart.getStyler().setYAxisTickMarkSpacingHint(1, 180); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(0, 35); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 180); paint(chart); assertThat(chart.axisPair.getYAxis(1).getAxisTickCalculator().getTickLocations().size()) .isLessThan(chart.axisPair.getYAxis(0).getAxisTickCalculator().getTickLocations().size()); @@ -83,8 +83,8 @@ void categoryChartUsesNumericYAxisGroupHints() { void mergedAxesFollowTheMasterSpacingHint() { XYChart chart = chart(); chart.getStyler().mergeYAxisGroups(0, 1); - chart.getStyler().setYAxisTickMarkSpacingHint(0, 180); - chart.getStyler().setYAxisTickMarkSpacingHint(1, 35); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(0, 180); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 35); paint(chart); assertThat(ticks(chart, 1)).isEqualTo(ticks(chart, 0)); } @@ -93,17 +93,62 @@ void mergedAxesFollowTheMasterSpacingHint() { void smallPlotsKeepExplicitGroupHints() { XYStyler styler = new XYStyler(); styler.setPlotContentSize(1.0); - styler.setYAxisTickMarkSpacingHint(0, 100); + styler.setYAxisGroupTickMarkSpacingHint(0, 100); AxisTickCalculator_Number wide = new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 100, styler, 0); - styler.setYAxisTickMarkSpacingHint(0, 30); + styler.setYAxisGroupTickMarkSpacingHint(0, 30); AxisTickCalculator_Number narrow = new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 100, styler, 0); assertThat(wide.getTickLocations().size()).isLessThan(narrow.getTickLocations().size()); - styler.setYAxisTickMarkSpacingHint(0, 200); + styler.setYAxisGroupTickMarkSpacingHint(0, 200); assertThat( new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 100, styler, 0) .getTickLocations()) + .containsExactly(0.0, 150.0); + } + + @Test + void oversizedGroupHintsKeepFormattedEndpoints() { + XYStyler styler = new XYStyler(); + styler.setPlotContentSize(0.8); + styler.setYAxisGroupTickMarkSpacingHint(1, Integer.MAX_VALUE); + AxisTickCalculator_Number numeric = + new AxisTickCalculator_Number(Axis_.Direction.Y, 150, -2.5, 7.5, styler, 1); + assertThat(numeric.getTickLabels()).containsExactly("-2.5", "7.5"); + assertThat(numeric.getTickLocations()).containsExactly(15.0, 135.0); + AxisTickCalculator_Callback callback = + new AxisTickCalculator_Callback( + value -> "value " + value, Axis_.Direction.Y, 150, -2.5, 7.5, styler, 1); + assertThat(callback.getTickLabels()).containsExactly("value -2.5", "value 7.5"); + assertThat(callback.getTickLocations()).containsExactly(15.0, 135.0); + assertThat( + new AxisTickCalculator_Number(Axis_.Direction.Y, 0, 0, 1, styler, 1).getTickLocations()) + .isEmpty(); + } + + @Test + void resizingKeepsLargeGroupHintsVisible() { + XYChart chart = new XYChartBuilder().width(900).height(600).build(); + double[] x = {0, 1, 2, 3, 4, 5}; + chart.addSeries("Measurement", x, new double[] {0, 20, 40, 60, 80, 100}); + chart.addSeries("Enabled", x, new double[] {0, 0, 1, 1, 0, 1}).setYAxisGroup(1); + chart.getStyler().setYAxisMin(1, 0.0); + chart.getStyler().setYAxisMax(1, 1.0); + chart.getStyler().setYAxisGroupTickMarkSpacingHint(1, 400); + for (int height : new int[] {600, 500, 400, 300, 600}) { + paint(chart, 900, height); + assertThat(chart.axisPair.getYAxis(1).getAxisTickCalculator().getTickLabels()) + .containsExactly("0", "1"); + assertThat(ticks(chart, 1)).hasSize(2); + } + } + + @Test + void globalOversizedHintKeepsExistingBehavior() { + XYStyler styler = new XYStyler(); + styler.setYAxisTickMarkSpacingHint(400); + assertThat( + new AxisTickCalculator_Number(Axis_.Direction.Y, 150, 0, 1, styler).getTickLocations()) .isEmpty(); } @@ -112,7 +157,7 @@ void xAxisDoesNotUseYAxisGroupHints() { XYStyler styler = new XYStyler(); AxisTickCalculator_Number before = new AxisTickCalculator_Number(Axis_.Direction.X, 800, 0, 100, styler); - styler.setYAxisTickMarkSpacingHint(0, 500); + styler.setYAxisGroupTickMarkSpacingHint(0, 500); AxisTickCalculator_Number after = new AxisTickCalculator_Number(Axis_.Direction.X, 800, 0, 100, styler); assertThat(after.getTickLocations()).isEqualTo(before.getTickLocations()); @@ -133,10 +178,14 @@ private List ticks(XYChart chart, int group) { } private void paint(Chart chart) { - BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB); + paint(chart, 800, 600); + } + + private void paint(Chart chart, int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D graphics = image.createGraphics(); try { - chart.paint(graphics, 800, 600); + chart.paint(graphics, width, height); } finally { graphics.dispose(); }