From 2d676448061de720ddccbe3e5411acc91bf2c1c4 Mon Sep 17 00:00:00 2001 From: Tim Molter Date: Thu, 28 May 2026 00:26:44 +0200 Subject: [PATCH] Fix #171: auto-skip crowded X-axis labels on CategoryChart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AxisTickCalculator_Category now respects xAxisTickMarkSpacingHint (default 74 px). When no explicit xAxisMaxLabelCount is set and the per-tick pixel spacing is smaller than the hint, a skipFactor is computed so only every Nth label is rendered — eliminating overlap automatically. Users can still override via setXAxisTickMarkSpacingHint() or setXAxisMaxLabelCount() for fine-grained control. Setting the hint to 0 disables auto-skip and restores the old 'show all' behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../standalone/issues/TestForIssue171.java | 64 +++++++++++++++++++ .../AxisTickCalculator_Category.java | 26 +++++--- .../AxisTickCalculatorCategoryTest.java | 32 ++++++++++ 3 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue171.java diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue171.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue171.java new file mode 100644 index 000000000..97a99d88f --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue171.java @@ -0,0 +1,64 @@ +package org.knowm.xchart.standalone.issues; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.knowm.xchart.CategoryChart; +import org.knowm.xchart.CategoryChartBuilder; +import org.knowm.xchart.CategorySeries; +import org.knowm.xchart.SwingWrapper; +import org.knowm.xchart.style.Styler; + +/** + * Demonstrates that x-axis labels no longer overlap when there are many categories. + * + *

Reproduces the 161-category histogram from issue #171. The default xAxisTickMarkSpacingHint + * now automatically skips labels on category charts so they don't overlap. + */ +public class TestForIssue171 { + + public static void main(String[] args) { + new SwingWrapper<>(getChart()).displayChart(); + } + + /** Constructs and returns the chart without launching a window (headless-safe). */ + public static CategoryChart getChart() { + int n = 80; + int nbRandomWalks = 1_000_000; + Random generator = new Random(42); + + int[] histogramY = new int[2 * n + 1]; + for (int j = 0; j < nbRandomWalks; j++) { + int delta = 0; + for (int i = 0; i < n; i++) { + delta += generator.nextInt(2) < 1 ? -1 : 1; + } + histogramY[delta + n] += 1; + } + + List xData = new ArrayList<>(); + List yData = new ArrayList<>(); + for (int i = 0; i < 2 * n + 1; i++) { + xData.add(i - n); + yData.add(histogramY[i]); + } + + CategoryChart chart = + new CategoryChartBuilder() + .width(800) + .height(600) + .title("Random Walk Distribution (Issue #171)") + .xAxisTitle("delta") + .yAxisTitle("occurrences") + .build(); + + chart.getStyler().setChartTitleVisible(true); + chart.getStyler().setLegendPosition(Styler.LegendPosition.InsideNW); + chart.getStyler().setLegendVisible(false); + chart.getStyler().setDefaultSeriesRenderStyle(CategorySeries.CategorySeriesRenderStyle.Stick); + + chart.addSeries("data", xData, yData); + + return chart; + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Category.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Category.java index a5c07e9b6..2e3cd5315 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Category.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickCalculator_Category.java @@ -78,6 +78,13 @@ private void calculate(List categories, Series.DataType axisType) { firstPosition = 0; } + // When no explicit label count cap is set, respect the spacing hint to avoid overlap. + // Compute how many categories to skip so adjacent labels are at least spacingHint pixels apart. + int skipFactor = 1; + if (xAxisMaxLabelCount == 0 && gridStep < styler.getXAxisTickMarkSpacingHint()) { + skipFactor = (int) Math.ceil(styler.getXAxisTickMarkSpacingHint() / gridStep); + } + // set up String formatters that may be encountered if (axisType == Series.DataType.String) { axisFormat = new Formatter_String(); @@ -96,16 +103,17 @@ private void calculate(List categories, Series.DataType axisType) { int counter = 0; for (Object category : categories) { - if (axisType == Series.DataType.String) { - tickLabels.add(category.toString()); - } else if (axisType == Series.DataType.Number) { - tickLabels.add(axisFormat.format(new BigDecimal(category.toString()).doubleValue())); - } else if (axisType == Series.DataType.Date) { - tickLabels.add(axisFormat.format((((Date) category).getTime()))); + if (counter % skipFactor == 0) { + if (axisType == Series.DataType.String) { + tickLabels.add(category.toString()); + } else if (axisType == Series.DataType.Number) { + tickLabels.add(axisFormat.format(new BigDecimal(category.toString()).doubleValue())); + } else if (axisType == Series.DataType.Date) { + tickLabels.add(axisFormat.format((((Date) category).getTime()))); + } + tickLocations.add(margin + firstPosition + gridStep * counter); } - - double tickLabelPosition = margin + firstPosition + gridStep * counter++; - tickLocations.add(tickLabelPosition); + counter++; } } } diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisTickCalculatorCategoryTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisTickCalculatorCategoryTest.java index 90c93ce8a..b2443e1a1 100644 --- a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisTickCalculatorCategoryTest.java +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisTickCalculatorCategoryTest.java @@ -59,4 +59,36 @@ public void shouldAllowAllLabelsIfThereisEnoughSpace() { assertThat(calculator.tickLocations) .isEqualTo(Arrays.asList(105.0, 243.0, 381.0, 519.0, 657.0, 795.0)); } + + @Test + public void shouldAutoSkipLabelsWhenCrowded() { + // 20 categories in 200px: gridStep ≈ 8.5 px < default spacingHint (74 px) + // skipFactor = ceil(74 / 8.5) = 9 → labels at indices 0, 9, 18 → 3 labels + List categories = + Arrays.asList( + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", + "r", "s", "t"); + CategoryStyler styler = new CategoryStyler(); + + AxisTickCalculator_Category calculator = + new AxisTickCalculator_Category( + Axis_.Direction.X, 200, categories, Series.DataType.String, styler); + + assertThat(calculator.tickLabels.size()).isLessThan(categories.size()); + assertThat(calculator.tickLabels.get(0)).isEqualTo("a"); + } + + @Test + public void shouldNotAutoSkipWhenSpacingHintIsDisabled() { + // Setting spacingHint to 0 disables auto-skip; all labels should appear + List categories = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j"); + CategoryStyler styler = new CategoryStyler(); + styler.setXAxisTickMarkSpacingHint(0); + + AxisTickCalculator_Category calculator = + new AxisTickCalculator_Category( + Axis_.Direction.X, 200, categories, Series.DataType.String, styler); + + assertThat(calculator.tickLabels.size()).isEqualTo(categories.size()); + } }