From ea7a34ac2cdbe2b638b0f6c24677fbad32835f88 Mon Sep 17 00:00:00 2001 From: Tim Molter Date: Fri, 21 Aug 2026 10:13:03 -0600 Subject: [PATCH] Pad OHLC x-axis range so edge candles are not clipped The x-axis range spanned exactly [dataMin, dataMax], centering the first and last candles on the plot boundaries and clipping half of each edge candle body. The only slack was the plot-content margin (~4% per side), which the candle half-width (xTickSpace / candleCount / 2) greatly exceeds for small candle counts. Pad the auto-computed range by half the candle spacing per side in AxisPair.overrideMinMaxForXAxis(), next to the existing horizontal-bar outside-labels padding. One candle period then maps to exactly the candle width PlotContent_OHLC draws, so edge candles fit fully. The spacing is the widest per-series median of consecutive x-deltas: median so weekend/holiday gaps in date data don't inflate the estimate, widest because the series with the fewest candles draws the widest bodies. Line-style OHLC series have no width and don't trigger padding, nor do logarithmic x-axes (padding would have to be multiplicative). Manual styler min/max overrides still win, as they are applied afterwards. Fixes #992 Co-Authored-By: Claude Fable 5 --- .../xchart/internal/chartpart/AxisPair.java | 46 +++++++++++ .../chartpart/OhlcEdgeCandlePaddingTest.java | 79 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 xchart/src/test/java/org/knowm/xchart/internal/chartpart/OhlcEdgeCandlePaddingTest.java 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 9c373b83e..3cae6d47c 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 @@ -2,17 +2,21 @@ import java.awt.Graphics2D; import java.awt.geom.Rectangle2D; +import java.util.Arrays; import java.util.List; import java.util.TreeMap; import org.knowm.xchart.CategorySeries; import org.knowm.xchart.CategorySeries.CategorySeriesRenderStyle; +import org.knowm.xchart.OHLCSeries; +import org.knowm.xchart.OHLCSeries.OHLCSeriesRenderStyle; import org.knowm.xchart.internal.series.AxesChartSeries; import org.knowm.xchart.internal.series.AxesChartSeriesCategory; import org.knowm.xchart.style.AxesChartStyler; import org.knowm.xchart.style.BoxStyler; import org.knowm.xchart.style.CategoryStyler; import org.knowm.xchart.style.HorizontalBarStyler; +import org.knowm.xchart.style.OHLCStyler; import org.knowm.xchart.style.Styler.LegendPosition; public class AxisPair implements ChartPart { @@ -282,6 +286,17 @@ private void overrideMinMaxForXAxis() { } } + if (chart.getStyler() instanceof OHLCStyler && !chart.getStyler().isXAxisLogarithmic()) { + + // The x-axis range spans exactly [dataMin, dataMax], which centers the first and last + // candles on the plot edges and clips half of each candle body (issue #992). Pad the range + // by half the candle spacing per side so edge candles render fully; one candle period then + // maps to exactly the candle width PlotContent_OHLC draws (xTickSpace / candleCount). + double halfSpacing = widestOhlcCandleSpacing() / 2.0; + overrideXAxisMinValue -= halfSpacing; + overrideXAxisMaxValue += halfSpacing; + } + // override min and maxValue if specified if (chart.getStyler().getXAxisMin() != null) { @@ -295,6 +310,37 @@ private void overrideMinMaxForXAxis() { xAxis.setMax(overrideXAxisMaxValue); } + /** + * The widest median x-spacing among the enabled candle-style OHLC series — the series with the + * fewest candles draws the widest bodies, so it needs the most padding. The median rather than + * the mean of the spacings is used so that gaps in date data (weekends, holidays) don't inflate + * the estimate. Returns 0 when no series needs edge padding (line render style has no width). + */ + private double widestOhlcCandleSpacing() { + + double widestSpacing = 0; + for (S series : chart.getSeriesMap().values()) { + if (!(series instanceof OHLCSeries) || !series.isEnabled()) { + continue; + } + OHLCSeries ohlcSeries = (OHLCSeries) series; + if (ohlcSeries.getOhlcSeriesRenderStyle() == OHLCSeriesRenderStyle.Line) { + continue; + } + double[] xData = ohlcSeries.getXData(); + if (xData == null || xData.length < 2) { + continue; + } + double[] spacings = new double[xData.length - 1]; + for (int i = 1; i < xData.length; i++) { + spacings[i - 1] = Math.abs(xData[i] - xData[i - 1]); + } + Arrays.sort(spacings); + widestSpacing = Math.max(widestSpacing, spacings[spacings.length / 2]); + } + return widestSpacing; + } + private void overrideMinMaxForYAxis(Axis_Y yAxis) { double overrideYAxisMinValue = yAxis.getMin(); diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/OhlcEdgeCandlePaddingTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/OhlcEdgeCandlePaddingTest.java new file mode 100644 index 000000000..6a7aa809f --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/OhlcEdgeCandlePaddingTest.java @@ -0,0 +1,79 @@ +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.OHLCChart; +import org.knowm.xchart.OHLCChartBuilder; +import org.knowm.xchart.OHLCSeries; + +// Issue #992: the x-axis range spanned exactly [dataMin, dataMax], so the first and last candles +// were centered on the plot's left/right boundaries and roughly half of each edge candle body was +// clipped. The axis range is now padded by half the candle spacing per side so edge candles render +// fully inside the plot. A chart with few, wide candles makes the clipping obvious. +// +// Renders to an off-screen image (no XChartPanel / Swing display) so it runs headless on CI. +class OhlcEdgeCandlePaddingTest { + + @Test + void edgeCandlesAreNotClippedAtPlotBoundaries() throws Exception { + + OHLCChart chart = new OHLCChartBuilder().width(500).height(400).build(); + chart.getStyler().setLegendVisible(false); + chart.getStyler().setPlotGridLinesVisible(false); + chart.getStyler().setPlotBackgroundColor(Color.YELLOW); + chart.getStyler().setPlotBorderVisible(false); + + double[] x = {1, 2, 3, 4}; + double[] open = {10, 12, 11, 13}; + double[] high = {13, 14, 13.5, 15}; + double[] low = {9, 10.5, 10, 12}; + double[] close = {12, 11, 13, 14}; + OHLCSeries series = chart.addSeries("s", x, open, high, low, close); + // one distinctive candle color regardless of up/down so the scan below is simple + series.setUpColor(Color.MAGENTA); + series.setDownColor(Color.MAGENTA); + + 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 py = 0; py < img.getHeight(); py++) { + for (int px = 0; px < img.getWidth(); px++) { + if (img.getRGB(px, py) == yellow) { + minX = Math.min(minX, px); + maxX = Math.max(maxX, px); + minY = Math.min(minY, py); + maxY = Math.max(maxY, py); + } + } + } + assertThat(maxX).as("plot area must be found").isGreaterThan(minX); + + // count candle-colored pixels per column band + int magenta = Color.MAGENTA.getRGB(); + int inEdgeColumns = 0; + int anywhere = 0; + for (int py = minY; py <= maxY; py++) { + for (int px = minX; px <= maxX; px++) { + if (img.getRGB(px, py) == magenta) { + anywhere++; + if (px <= minX + 1 || px >= maxX - 1) { + inEdgeColumns++; + } + } + } + } + + assertThat(anywhere).as("candles must be painted").isPositive(); + // Without the axis padding the first/last candle bodies are cut off by the plot boundary, so + // candle pixels sit directly in the outermost plot columns. + assertThat(inEdgeColumns) + .as("no candle pixels may touch the plot's left/right boundary columns") + .isZero(); + } +}