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;
+ }
+}