Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ST extends AxesChartStyler, S extends AxesChartSeries> implements ChartPart {
Expand Down Expand Up @@ -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) {

Expand All @@ -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<ST, S> yAxis) {

double overrideYAxisMinValue = yAxis.getMin();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading