From c0e67921f2056d8e8fcdd878992efb240fdf04c2 Mon Sep 17 00:00:00 2001 From: Tim Molter Date: Fri, 17 Jul 2026 11:27:36 +0200 Subject: [PATCH 1/2] Issue #577: wrap horizontal legends instead of overflowing the image A horizontal legend (LegendLayout.Horizontal) laid every entry out on a single row. With many series, or long series names, that row grew wider than the image, so the centered OutsideS legend spilled past both edges and was truncated. Entries now flow left-to-right and wrap onto additional rows once a row fills up, keyed off the chart width so the legend box always stays within the image. Sizing (Legend_.getBoundsHintHorizontal) and painting (each Legend_* subclass) walk the same series in the same order using the same per-entry advance width, so they wrap at identical points and the plot reserves the correct vertical space below it. - Legend_: wrap logic in getBoundsHintHorizontal, shared HorizontalCursor and helpers (row height, max row width, per-entry advance width). - Legend_Marker/Pie/Bubble/HorizontalBar/OHLC: lay out horizontal entries via the cursor. OHLC overrides the marker width (series-line length). - HeatMap legend is unaffected (custom layout, not series labels). - Add RegressionTestIssue577 and TestForIssue577 demo. Co-Authored-By: Claude Opus 4.8 --- .../standalone/issues/TestForIssue577.java | 64 ++++++++ .../xchart/internal/chartpart/Legend_.java | 142 ++++++++++++++---- .../internal/chartpart/Legend_Bubble.java | 27 +++- .../chartpart/Legend_HorizontalBar.java | 28 ++-- .../internal/chartpart/Legend_Marker.java | 57 +++---- .../internal/chartpart/Legend_OHLC.java | 32 +++- .../xchart/internal/chartpart/Legend_Pie.java | 27 +++- .../chartpart/RegressionTestIssue577.java | 67 +++++++++ 8 files changed, 350 insertions(+), 94 deletions(-) create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue577.java create mode 100644 xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java 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 000000000..43e9dba6c --- /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 a4fc4a653..c4f6711a5 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 @@ -230,11 +230,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(); - // determine total legend content width - double legendContentWidth = 0; + // 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(); + + double currentRowWidth = 0; + double widestRow = 0; + int rowCount = 1; Map map = chart.getSeriesMap(); for (S series : map.values()) { @@ -246,41 +255,114 @@ 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; - legendContentWidth += legendEntryMaxWidth + chart.getStyler().getLegendPadding(); + return new Rectangle2D.Double(0, 0, width, height); // 0 indicates not sure yet. + } - if (series.getLegendRenderType() == LegendRenderType.Line) { - legendContentWidth = - chart.getStyler().getLegendSeriesLineLength() - + chart.getStyler().getLegendPadding() - + legendContentWidth; - } else { - legendContentWidth = BOX_SIZE + chart.getStyler().getLegendPadding() + legendContentWidth; + /** + * 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() { + + 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 or box) preceding an entry's text. Subclasses whose + * graphic isn't sized by render type (e.g. OHLC) override this. + */ + int getLegendEntryMarkerWidth(S series) { + + return series.getLegendRenderType() == LegendRenderType.Line + ? 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 c0f7f951a..d1d42a987 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 c899eea39..33a886f09 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 3eba2afcd..e8768bb96 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 8a7d5709d..130afefc5 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 d955e59d6..741e47297 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 000000000..cee33576f --- /dev/null +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java @@ -0,0 +1,67 @@ +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, keeping the legend box within the image + * width. + */ +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. + BitmapEncoder.getBufferedImage(wrapped); + + // getLegend() is package-private in Chart and not inherited by XYChart (different package), so + // reach it through the Chart type. + double wrappedWidth = ((Chart) wrapped).getLegend().getBounds().getWidth(); + double wrappedHeight = ((Chart) wrapped).getLegend().getBounds().getHeight(); + + // (1) The legend box must fit within 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 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").build(); + chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideS); + chart.getStyler().setLegendLayout(Styler.LegendLayout.Horizontal); + + 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, i + 1.0}); + } + return chart; + } +} From 3a8791593bf77ad5faf07ce968a6ecf4ff73a497 Mon Sep 17 00:00:00 2001 From: Tim Molter Date: Sun, 19 Jul 2026 11:19:08 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Issue=20#577:=20address=20review=20?= =?UTF-8?q?=E2=80=94=20clamp=20legend=20into=20image,=20fix=20Scatter=20ad?= =?UTF-8?q?vance=20width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the horizontal-legend wrapping change: 1. Right-edge truncation: an OutsideS legend is centered on the plot, whose center sits right of the image center (the left y-axis consumes space), so a wide wrapped legend could still overflow the right image edge even though each row fit within getHorizontalLegendMaxRowWidth(). Clamp xOffset into [LEGEND_MARGIN, chartWidth - boundsWidth - LEGEND_MARGIN] for OutsideS. 2. Scatter advance width: getLegendEntryMarkerWidth() only special-cased Line, but Legend_Marker paints Scatter text at the series-line-length offset too, so a Scatter entry under-advanced by (lineLength - BOX_SIZE) and could pack a row too tight. Treat Scatter like Line. RegressionTestIssue577 now also asserts the box sits fully within the image (position, not just width), using wide y-axis tick labels to push the plot right so the assertion genuinely exercises the clamp (fails by ~40px without it). Co-Authored-By: Claude Opus 4.8 --- .../xchart/internal/chartpart/Legend_.java | 18 ++++++++-- .../chartpart/RegressionTestIssue577.java | 35 +++++++++++++------ 2 files changed, 41 insertions(+), 12 deletions(-) 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 c4f6711a5..a938eb623 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()); @@ -311,12 +322,15 @@ private Rectangle2D getBoundsHintHorizontal() { } /** - * The width of the legend graphic (line or box) preceding an entry's text. Subclasses whose + * 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 + return (series.getLegendRenderType() == LegendRenderType.Line + || series.getLegendRenderType() == LegendRenderType.Scatter) ? chart.getStyler().getLegendSeriesLineLength() : BOX_SIZE; } 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 index cee33576f..93fa7f03b 100644 --- a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java +++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/RegressionTestIssue577.java @@ -13,8 +13,8 @@ * *

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, keeping the legend box within the image - * width. + * 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 { @@ -24,21 +24,32 @@ public class RegressionTestIssue577 { public void manySeriesHorizontalLegendWrapsAndStaysWithinImageWidth() { XYChart wrapped = horizontalLegendChart(12); - // Rendering forces the layout pass that computes the legend bounds. + // 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. - double wrappedWidth = ((Chart) wrapped).getLegend().getBounds().getWidth(); - double wrappedHeight = ((Chart) wrapped).getLegend().getBounds().getHeight(); + Legend_ legend = ((Chart) wrapped).getLegend(); + double wrappedWidth = legend.getBounds().getWidth(); + double wrappedHeight = legend.getBounds().getHeight(); - // (1) The legend box must fit within the image. Pre-fix, its width was the sum of every entry's - // width and far exceeded the chart width. + // (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 entries must have wrapped onto more than one row, so the box is taller than a single + // (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); @@ -52,15 +63,19 @@ public void manySeriesHorizontalLegendWrapsAndStaysWithinImageWidth() { private static XYChart horizontalLegendChart(int seriesCount) { XYChart chart = - new XYChartBuilder().width(CHART_WIDTH).height(600).title("issue 577").build(); + 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, i + 1.0}); + new double[] {i * 123456789.0, (i + 1) * 123456789.0}); } return chart; }