diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue577.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue577.java new file mode 100644 index 00000000..43e9dba6 --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue577.java @@ -0,0 +1,64 @@ +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; + +/** + * Reproducer for https://github.com/knowm/XChart/issues/577 + * + *

"When displaying horizontal legends, and too many series, the legends are extending beyond the + * chart, and beyond the image, and are truncated. This happens often when the series names are + * long." (reported with a horizontal, OutsideS legend) + * + *

Expected: legends should go to the next line when the current line is too long. + * + *

Before the fix, all legend entries were laid out on a single row, so the centered OutsideS + * legend spilled past both image edges and got truncated. Entries now wrap onto additional rows, + * keeping the whole legend within the image width. + */ +public class TestForIssue577 { + + public static void main(String[] args) { + + new SwingWrapper<>(getChart()).displayChart(); + } + + /** Constructs and returns the chart without launching a window (headless-safe). */ + public static XYChart getChart() { + + // Create chart + XYChart chart = + new XYChartBuilder() + .width(900) + .height(500) + .title("Horizontal Legend Wrapping Demo") + .xAxisTitle("X") + .yAxisTitle("Y") + .build(); + + // General Styler settings: a horizontal legend below the plot + chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideS); + chart.getStyler().setLegendLayout(Styler.LegendLayout.Horizontal); + chart.getStyler().setLegendVisible(true); + + // Many series with long names so a single legend row would overflow the image width + String[] seriesNames = { + "Temperature Sensor North", + "Temperature Sensor South", + "Humidity Sensor East Wing", + "Pressure Gauge Basement", + "Wind Speed Rooftop Array", + "Solar Irradiance Panel 12", + "CO2 Concentration Lobby", + "Particulate Matter PM2.5" + }; + for (int i = 0; i < seriesNames.length; i++) { + chart.addSeries( + seriesNames[i], new double[] {0, 1, 2}, new double[] {i, i + 1, i}); + } + + return chart; + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_.java index a4fc4a65..a938eb62 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_.java @@ -140,6 +140,17 @@ public void paint(Graphics2D g) { break; } + // An OutsideS legend is centered on the plot, whose center sits right of the image center (the + // left y-axis consumes horizontal space). A wide (wrapped) horizontal legend can therefore + // still run off the right image edge even though every row fits within + // getHorizontalLegendMaxRowWidth(). Clamp the box so it always stays fully within the image + // (issue #577). For a legend narrower than the image this only nudges it left when it would + // otherwise be cut off; a normal centered legend is left untouched. + if (chart.getStyler().getLegendPosition() == Styler.LegendPosition.OutsideS) { + xOffset = Math.min(xOffset, chart.getWidth() - bounds.getWidth() - LEGEND_MARGIN); + xOffset = Math.max(xOffset, LEGEND_MARGIN); + } + // draw legend box background and border Shape rect = new Rectangle2D.Double(xOffset, yOffset, bounds.getWidth(), height); g.setColor(chart.getStyler().getLegendBackgroundColor()); @@ -230,11 +241,20 @@ private Rectangle2D getBoundsHintHorizontal() { // 0). } - // determine legend text content max height - double legendTextContentMaxHeight = 0; + // All rows in a wrapping horizontal legend share the same (tallest) row height so entries line + // up regardless of series order (issue #892). + double rowHeight = computeHorizontalRowHeight(); + + // Entries flow left-to-right and wrap to a new row once the current row would exceed the + // available width, so a legend with many (or long-named) series no longer spills past the image + // edge (issue #577). getBoundsHintHorizontal() and the subclass doPaint() methods walk the same + // series in the same order using the same per-entry advance width, so they wrap at identical + // points and the reported box matches what is painted. + double maxRowWidth = getHorizontalLegendMaxRowWidth(); - // determine total legend content width - double legendContentWidth = 0; + double currentRowWidth = 0; + double widestRow = 0; + int rowCount = 1; Map map = chart.getSeriesMap(); for (S series : map.values()) { @@ -246,41 +266,117 @@ private Rectangle2D getBoundsHintHorizontal() { continue; } - Map seriesTextBounds = getSeriesTextBounds(series); + double entryAdvanceWidth = getHorizontalLegendEntryAdvanceWidth(series); - double legendEntryHeight = 0; // could be multi-line - double legendEntryMaxWidth = 0; // could be multi-line - for (Map.Entry entry : seriesTextBounds.entrySet()) { - legendEntryHeight += entry.getValue().getHeight() + MULTI_LINE_SPACE; - legendEntryMaxWidth = Math.max(legendEntryMaxWidth, entry.getValue().getWidth()); + // Wrap to the next row when this entry would overflow the current one (but never wrap an + // empty row, so a single over-wide entry still gets its own row). + if (currentRowWidth > 0 && currentRowWidth + entryAdvanceWidth > maxRowWidth) { + widestRow = Math.max(widestRow, currentRowWidth); + rowCount++; + currentRowWidth = 0; } + currentRowWidth += entryAdvanceWidth; + } + widestRow = Math.max(widestRow, currentRowWidth); - legendEntryHeight -= MULTI_LINE_SPACE; // subtract away the bottom MULTI_LINE_SPACE - // Accumulate the tallest entry across ALL series (text or graphic, whichever is taller) so - // the single-row horizontal legend box is tall enough for the biggest entry (e.g. a 20px - // box) regardless of series order (issue #892). - legendTextContentMaxHeight = - Math.max( - legendTextContentMaxHeight, - Math.max(legendEntryHeight, getSeriesLegendRenderGraphicHeight(series))); + // Legend Box. For a single row this reduces to the previous formula + // (widestRow + padding wide, rowHeight + 2*padding tall). + double width = widestRow + chart.getStyler().getLegendPadding(); + double height = + rowCount * rowHeight + + (rowCount - 1) * chart.getStyler().getLegendPadding() + + chart.getStyler().getLegendPadding() * 2; + + return new Rectangle2D.Double(0, 0, width, height); // 0 indicates not sure yet. + } - legendContentWidth += legendEntryMaxWidth + chart.getStyler().getLegendPadding(); + /** + * The tallest legend entry across all shown series (text or graphic, whichever is taller). Every + * row in a horizontal legend uses this so entries share a common baseline (issue #892). + */ + double computeHorizontalRowHeight() { - if (series.getLegendRenderType() == LegendRenderType.Line) { - legendContentWidth = - chart.getStyler().getLegendSeriesLineLength() - + chart.getStyler().getLegendPadding() - + legendContentWidth; - } else { - legendContentWidth = BOX_SIZE + chart.getStyler().getLegendPadding() + legendContentWidth; + double rowHeight = 0; + for (S series : chart.getSeriesMap().values()) { + if (!series.isShowInLegend() || !series.isEnabled()) { + continue; } + rowHeight = + Math.max( + rowHeight, + getLegendEntryHeight( + getSeriesTextBounds(series), (int) getSeriesLegendRenderGraphicHeight(series))); } + return rowHeight; + } - // Legend Box - double width = legendContentWidth + chart.getStyler().getLegendPadding(); - double height = legendTextContentMaxHeight + chart.getStyler().getLegendPadding() * 2; + /** + * The horizontal distance a single legend entry consumes, including the trailing padding that + * separates it from the next entry. Used by both bounds calculation and painting so they wrap at + * exactly the same points. + */ + double getHorizontalLegendEntryAdvanceWidth(S series) { - return new Rectangle2D.Double(0, 0, width, height); // 0 indicates not sure yet. + return getLegendEntryWidth(getSeriesTextBounds(series), getLegendEntryMarkerWidth(series)) + + chart.getStyler().getLegendPadding(); + } + + /** + * The width of the legend graphic (line/marker or box) preceding an entry's text. Line and + * Scatter entries reserve the series-line length (their text is painted at that offset, with the + * marker centered within it); box-style entries reserve {@link #BOX_SIZE}. Subclasses whose + * graphic isn't sized by render type (e.g. OHLC) override this. + */ + int getLegendEntryMarkerWidth(S series) { + + return (series.getLegendRenderType() == LegendRenderType.Line + || series.getLegendRenderType() == LegendRenderType.Scatter) + ? chart.getStyler().getLegendSeriesLineLength() + : BOX_SIZE; + } + + /** + * The maximum width one row of a horizontal legend may occupy before wrapping. Keyed off the + * chart width (minus margin and padding) so the centered OutsideS legend box never extends past + * the image edge (issue #577). + */ + double getHorizontalLegendMaxRowWidth() { + + return chart.getWidth() - 2.0 * LEGEND_MARGIN - 2.0 * chart.getStyler().getLegendPadding(); + } + + /** + * A left-to-right pen for laying out a wrapping horizontal legend. Subclass painters advance it + * per entry and read {@link #x}/{@link #y} as the current entry's origin. + */ + final class HorizontalCursor { + + final double leftOrigin; + final double rowHeight; + private final double maxRowWidth; + double x; + double y; + + HorizontalCursor(double startx, double starty) { + this.leftOrigin = startx; + this.x = startx; + this.y = starty; + this.rowHeight = computeHorizontalRowHeight(); + this.maxRowWidth = getHorizontalLegendMaxRowWidth(); + } + + /** Wrap to the next row if placing an entry of the given advance width would overflow. */ + void maybeWrap(double entryAdvanceWidth) { + if (x > leftOrigin && (x - leftOrigin) + entryAdvanceWidth > maxRowWidth) { + x = leftOrigin; + y += rowHeight + chart.getStyler().getLegendPadding(); + } + } + + /** Move past an entry of the given advance width. */ + void advance(double entryAdvanceWidth) { + x += entryAdvanceWidth; + } } /** diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Bubble.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Bubble.java index c0f7f951..d1d42a98 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Bubble.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Bubble.java @@ -40,6 +40,12 @@ public void doPaint(Graphics2D g) { : RenderingHints.VALUE_ANTIALIAS_OFF); Map map = chart.getSeriesMap(); + + // In a horizontal legend, entries flow across shared rows and wrap to a new row when a row + // fills up (issue #577); in a vertical legend each entry gets its own row. + boolean isHorizontal = chart.getStyler().getLegendLayout() == Styler.LegendLayout.Horizontal; + HorizontalCursor cursor = isHorizontal ? new HorizontalCursor(startx, starty) : null; + for (S series : map.values()) { if (!series.isShowInLegend()) { @@ -52,6 +58,16 @@ public void doPaint(Graphics2D g) { Map seriesTextBounds = getSeriesTextBounds(series); float legendEntryHeight = getLegendEntryHeight(seriesTextBounds, BOX_SIZE); + double entryAdvanceWidth = 0; + if (isHorizontal) { + entryAdvanceWidth = + getLegendEntryWidth(seriesTextBounds, getLegendEntryMarkerWidth(series)) + + chart.getStyler().getLegendPadding(); + cursor.maybeWrap(entryAdvanceWidth); + startx = cursor.x; + starty = cursor.y; + } + // paint little circle Shape rectSmall = new Ellipse2D.Double(startx, starty, BOX_SIZE, BOX_SIZE); g.setColor(series.getFillColor()); @@ -64,15 +80,10 @@ public void doPaint(Graphics2D g) { final double x = startx + BOX_SIZE + chart.getStyler().getLegendPadding(); paintSeriesText(g, seriesTextBounds, BOX_SIZE, x, starty); - if (chart.getStyler().getLegendLayout() == Styler.LegendLayout.Vertical) { - starty += legendEntryHeight + chart.getStyler().getLegendPadding(); + if (isHorizontal) { + cursor.advance(entryAdvanceWidth); } else { - int markerWidth = BOX_SIZE; - if (series.getLegendRenderType() == LegendRenderType.Line) { - markerWidth = chart.getStyler().getLegendSeriesLineLength(); - } - float legendEntryWidth = getLegendEntryWidth(seriesTextBounds, markerWidth); - startx += legendEntryWidth + chart.getStyler().getLegendPadding(); + starty += legendEntryHeight + chart.getStyler().getLegendPadding(); } } g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldHint); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_HorizontalBar.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_HorizontalBar.java index c899eea3..33a886f0 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_HorizontalBar.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_HorizontalBar.java @@ -4,7 +4,6 @@ import java.awt.geom.Rectangle2D; import java.util.Map; import org.knowm.xchart.HorizontalBarSeries; -import org.knowm.xchart.internal.chartpart.RenderableSeries.LegendRenderType; import org.knowm.xchart.style.Styler; public class Legend_HorizontalBar @@ -35,6 +34,12 @@ public void doPaint(Graphics2D g) { : RenderingHints.VALUE_ANTIALIAS_OFF); Map map = chart.getSeriesMap(); + + // In a horizontal legend, entries flow across shared rows and wrap to a new row when a row + // fills up (issue #577); in a vertical legend each entry gets its own row. + boolean isHorizontal = chart.getStyler().getLegendLayout() == Styler.LegendLayout.Horizontal; + HorizontalCursor cursor = isHorizontal ? new HorizontalCursor(startx, starty) : null; + for (S series : map.values()) { if (!series.isShowInLegend()) { @@ -47,7 +52,15 @@ public void doPaint(Graphics2D g) { Map seriesTextBounds = getSeriesTextBounds(series); float legendEntryHeight = getLegendEntryHeight(seriesTextBounds, BOX_SIZE); - // paint line and marker + double entryAdvanceWidth = 0; + if (isHorizontal) { + entryAdvanceWidth = + getLegendEntryWidth(seriesTextBounds, getLegendEntryMarkerWidth(series)) + + chart.getStyler().getLegendPadding(); + cursor.maybeWrap(entryAdvanceWidth); + startx = cursor.x; + starty = cursor.y; + } // paint inner box Shape rectSmall = new Rectangle2D.Double(startx, starty, BOX_SIZE, BOX_SIZE); @@ -58,15 +71,10 @@ public void doPaint(Graphics2D g) { double x = startx + BOX_SIZE + chart.getStyler().getLegendPadding(); paintSeriesText(g, seriesTextBounds, BOX_SIZE, x, starty); - if (chart.getStyler().getLegendLayout() == Styler.LegendLayout.Vertical) { - starty += legendEntryHeight + chart.getStyler().getLegendPadding(); + if (isHorizontal) { + cursor.advance(entryAdvanceWidth); } else { - int markerWidth = BOX_SIZE; - if (series.getLegendRenderType() == LegendRenderType.Line) { - markerWidth = chart.getStyler().getLegendSeriesLineLength(); - } - float legendEntryWidth = getLegendEntryWidth(seriesTextBounds, markerWidth); - startx += legendEntryWidth + chart.getStyler().getLegendPadding(); + starty += legendEntryHeight + chart.getStyler().getLegendPadding(); } } g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldHint); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Marker.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Marker.java index 3eba2afc..e8768bb9 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Marker.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Marker.java @@ -41,26 +41,13 @@ public void doPaint(Graphics2D g) { Map map = chart.getSeriesMap(); - // In a horizontal legend all entries share one row, so they must be vertically centered - // against a common row height. Otherwise mixed render styles (e.g. a Bar's 20px box vs. a - // Line's smaller marker) center against their own graphic height and sit at different - // baselines (issue #892). In a vertical legend each entry gets its own row, so the per-entry - // height is the correct reference. - boolean isHorizontal = - chart.getStyler().getLegendLayout() == Styler.LegendLayout.Horizontal; - float commonRowHeight = 0; - if (isHorizontal) { - for (S series : map.values()) { - if (!series.isShowInLegend() || !series.isEnabled()) { - continue; - } - commonRowHeight = - Math.max( - commonRowHeight, - getLegendEntryHeight( - getSeriesTextBounds(series), (int) getSeriesLegendRenderGraphicHeight(series))); - } - } + // In a horizontal legend entries flow across shared rows (wrapping to a new row when a row + // fills up - issue #577), and all entries in a row are vertically centered against a common row + // height. Otherwise mixed render styles (e.g. a Bar's 20px box vs. a Line's smaller marker) + // center against their own graphic height and sit at different baselines (issue #892). In a + // vertical legend each entry gets its own row, so the per-entry height is the correct reference. + boolean isHorizontal = chart.getStyler().getLegendLayout() == Styler.LegendLayout.Horizontal; + HorizontalCursor cursor = isHorizontal ? new HorizontalCursor(startx, starty) : null; for (S series : map.values()) { @@ -80,12 +67,21 @@ public void doPaint(Graphics2D g) { ? axesChartStyler.getMarkerSize() : BOX_SIZE)); - // In a horizontal layout every entry shares one row, so center this entry's natural block - // within the shared row by shifting its vertical origin. All per-element math below then - // stays exactly as in the vertical layout, just drawn from entryStarty. In a vertical layout - // each entry has its own row, so the shift is zero. + // In a horizontal layout, position this entry via the wrapping cursor and center its natural + // block within the shared row by shifting its vertical origin. All per-element math below then + // stays exactly as in the vertical layout, just drawn from startx/entryStarty. In a vertical + // layout each entry has its own row, so the shift is zero. + double entryAdvanceWidth = 0; + if (isHorizontal) { + entryAdvanceWidth = + getLegendEntryWidth(seriesTextBounds, getLegendEntryMarkerWidth(series)) + + chart.getStyler().getLegendPadding(); + cursor.maybeWrap(entryAdvanceWidth); + startx = cursor.x; + } + double entryStarty = - isHorizontal ? starty + (commonRowHeight - legendEntryHeight) / 2.0 : starty; + isHorizontal ? cursor.y + (cursor.rowHeight - legendEntryHeight) / 2.0 : starty; // paint line and marker if (series.getLegendRenderType() == LegendRenderType.Line @@ -175,15 +171,10 @@ public void doPaint(Graphics2D g) { paintSeriesText(g, seriesTextBounds, BOX_SIZE, x, entryStarty); } - if (chart.getStyler().getLegendLayout() == Styler.LegendLayout.Vertical) { - starty += legendEntryHeight + chart.getStyler().getLegendPadding(); + if (isHorizontal) { + cursor.advance(entryAdvanceWidth); } else { - int markerWidth = BOX_SIZE; - if (series.getLegendRenderType() == LegendRenderType.Line) { - markerWidth = chart.getStyler().getLegendSeriesLineLength(); - } - float legendEntryWidth = getLegendEntryWidth(seriesTextBounds, markerWidth); - startx += legendEntryWidth + chart.getStyler().getLegendPadding(); + starty += legendEntryHeight + chart.getStyler().getLegendPadding(); } } g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldHint); diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_OHLC.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_OHLC.java index 8a7d5709..130afefc 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_OHLC.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_OHLC.java @@ -41,6 +41,12 @@ public void doPaint(Graphics2D g) { : RenderingHints.VALUE_ANTIALIAS_OFF); Map map = chart.getSeriesMap(); + + // In a horizontal legend, entries flow across shared rows and wrap to a new row when a row + // fills up (issue #577); in a vertical legend each entry gets its own row. + boolean isHorizontal = chart.getStyler().getLegendLayout() == Styler.LegendLayout.Horizontal; + HorizontalCursor cursor = isHorizontal ? new HorizontalCursor(startx, starty) : null; + for (S series : map.values()) { if (!series.isShowInLegend()) { @@ -54,6 +60,16 @@ public void doPaint(Graphics2D g) { float legendEntryHeight = getLegendEntryHeight(seriesTextBounds, axesChartStyler.getMarkerSize()); + double entryAdvanceWidth = 0; + if (isHorizontal) { + entryAdvanceWidth = + getLegendEntryWidth(seriesTextBounds, getLegendEntryMarkerWidth(series)) + + chart.getStyler().getLegendPadding(); + cursor.maybeWrap(entryAdvanceWidth); + startx = cursor.x; + starty = cursor.y; + } + if (series.getOhlcSeriesRenderStyle() != OHLCSeriesRenderStyle.Line) { Shape rectSmall = @@ -105,12 +121,10 @@ public void doPaint(Graphics2D g) { + chart.getStyler().getLegendPadding(); paintSeriesText(g, seriesTextBounds, axesChartStyler.getMarkerSize(), x, starty); - if (chart.getStyler().getLegendLayout() == Styler.LegendLayout.Vertical) { - starty += legendEntryHeight + chart.getStyler().getLegendPadding(); + if (isHorizontal) { + cursor.advance(entryAdvanceWidth); } else { - int markerWidth = chart.getStyler().getLegendSeriesLineLength(); - float legendEntryWidth = getLegendEntryWidth(seriesTextBounds, markerWidth); - startx += legendEntryWidth + chart.getStyler().getLegendPadding(); + starty += legendEntryHeight + chart.getStyler().getLegendPadding(); } } g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldHint); @@ -124,4 +138,12 @@ public double getSeriesLegendRenderGraphicHeight(S series) { ? BOX_SIZE : axesChartStyler.getMarkerSize(); } + + @Override + int getLegendEntryMarkerWidth(S series) { + + // An OHLC legend graphic is always drawn at the series-line length (candle box or line), not the + // render-type default, so the advance width used for wrapping matches what is painted. + return chart.getStyler().getLegendSeriesLineLength(); + } } diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Pie.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Pie.java index d955e59d..741e4729 100644 --- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Pie.java +++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_Pie.java @@ -33,6 +33,12 @@ public void doPaint(Graphics2D g) { : RenderingHints.VALUE_ANTIALIAS_OFF); Map map = chart.getSeriesMap(); + + // In a horizontal legend, entries flow across shared rows and wrap to a new row when a row + // fills up (issue #577); in a vertical legend each entry gets its own row. + boolean isHorizontal = chart.getStyler().getLegendLayout() == Styler.LegendLayout.Horizontal; + HorizontalCursor cursor = isHorizontal ? new HorizontalCursor(startx, starty) : null; + for (S series : map.values()) { if (!series.isShowInLegend()) { @@ -45,6 +51,16 @@ public void doPaint(Graphics2D g) { Map seriesTextBounds = getSeriesTextBounds(series); float legendEntryHeight = getLegendEntryHeight(seriesTextBounds, BOX_SIZE); + double entryAdvanceWidth = 0; + if (isHorizontal) { + entryAdvanceWidth = + getLegendEntryWidth(seriesTextBounds, getLegendEntryMarkerWidth(series)) + + chart.getStyler().getLegendPadding(); + cursor.maybeWrap(entryAdvanceWidth); + startx = cursor.x; + starty = cursor.y; + } + // paint little box Shape rectSmall = new Rectangle2D.Double(startx, starty, BOX_SIZE, BOX_SIZE); g.setColor(series.getFillColor()); @@ -54,15 +70,10 @@ public void doPaint(Graphics2D g) { final double x = startx + BOX_SIZE + chart.getStyler().getLegendPadding(); paintSeriesText(g, seriesTextBounds, BOX_SIZE, x, starty); - if (chart.getStyler().getLegendLayout() == Styler.LegendLayout.Vertical) { - starty += legendEntryHeight + chart.getStyler().getLegendPadding(); + if (isHorizontal) { + cursor.advance(entryAdvanceWidth); } else { - int markerWidth = BOX_SIZE; - if (series.getLegendRenderType() == RenderableSeries.LegendRenderType.Line) { - markerWidth = chart.getStyler().getLegendSeriesLineLength(); - } - float legendEntryWidth = getLegendEntryWidth(seriesTextBounds, markerWidth); - startx += legendEntryWidth + chart.getStyler().getLegendPadding(); + starty += legendEntryHeight + chart.getStyler().getLegendPadding(); } } diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java new file mode 100644 index 00000000..93fa7f03 --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java @@ -0,0 +1,82 @@ +package org.knowm.xchart.internal.chartpart; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.knowm.xchart.BitmapEncoder; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; +import org.knowm.xchart.style.Styler; + +/** + * Regression test for issue 577. + * + *

A horizontal legend used to lay every entry out on a single row. With many (or long-named) + * series that row grew wider than the image, so the centered OutsideS legend spilled past both edges + * and got truncated. Entries now wrap onto additional rows, and the whole box is kept within the + * image, so nothing is truncated. + */ +public class RegressionTestIssue577 { + + private static final int CHART_WIDTH = 800; + + @Test + public void manySeriesHorizontalLegendWrapsAndStaysWithinImageWidth() { + + XYChart wrapped = horizontalLegendChart(12); + // Rendering forces the layout pass that computes the legend bounds and position. + BitmapEncoder.getBufferedImage(wrapped); + + // getLegend() is package-private in Chart and not inherited by XYChart (different package), so + // reach it through the Chart type. + Legend_ legend = ((Chart) wrapped).getLegend(); + double wrappedWidth = legend.getBounds().getWidth(); + double wrappedHeight = legend.getBounds().getHeight(); + + // (1) The legend box must be no wider than the image. Pre-fix, its width was the sum of every + // entry's width and far exceeded the chart width. + assertThat(wrappedWidth) + .as("wrapped horizontal legend must not be wider than the image") + .isLessThanOrEqualTo((double) CHART_WIDTH); + + // (2) The box must sit fully within the image. The OutsideS legend is centered on the plot, + // whose center is right of the image center (the left y-axis takes horizontal space), so a wide + // wrapped legend used to still overflow the right edge until it was clamped into the image. + assertThat(legend.xOffset) + .as("legend box must not extend past the left image edge") + .isGreaterThanOrEqualTo(0.0); + assertThat(legend.xOffset + wrappedWidth) + .as("legend box must not extend past the right image edge") + .isLessThanOrEqualTo((double) CHART_WIDTH); + + // (3) The entries must have wrapped onto more than one row, so the box is taller than a single + // row. Compare against an otherwise-identical single-series (single-row) legend. + XYChart singleRow = horizontalLegendChart(1); + BitmapEncoder.getBufferedImage(singleRow); + double singleRowHeight = ((Chart) singleRow).getLegend().getBounds().getHeight(); + + assertThat(wrappedHeight) + .as("wrapped horizontal legend must be taller than a single row") + .isGreaterThan(singleRowHeight); + } + + private static XYChart horizontalLegendChart(int seriesCount) { + + XYChart chart = + new XYChartBuilder().width(CHART_WIDTH).height(600).title("issue 577").yAxisTitle("Y").build(); + chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideS); + chart.getStyler().setLegendLayout(Styler.LegendLayout.Horizontal); + // Wide y-axis tick labels enlarge the left margin, pushing the plot center well right of the + // image center. That is what let the centered legend overflow the right edge pre-fix (here by + // ~40px), so assertion (2) meaningfully exercises the clamp. + chart.getStyler().setYAxisDecimalPattern("###,###,###,##0.0000"); + + for (int i = 0; i < seriesCount; i++) { + chart.addSeries( + "A Fairly Long Series Name Number " + i, + new double[] {0.0, 1.0}, + new double[] {i * 123456789.0, (i + 1) * 123456789.0}); + } + return chart; + } +}