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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
*
* <p>"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)
*
* <p>Expected: legends should go to the next line when the current line is too long.
*
* <p>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;
}
}
156 changes: 126 additions & 30 deletions xchart/src/main/java/org/knowm/xchart/internal/chartpart/Legend_.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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<String, S> map = chart.getSeriesMap();
for (S series : map.values()) {
Expand All @@ -246,41 +266,117 @@ private Rectangle2D getBoundsHintHorizontal() {
continue;
}

Map<String, Rectangle2D> seriesTextBounds = getSeriesTextBounds(series);
double entryAdvanceWidth = getHorizontalLegendEntryAdvanceWidth(series);

double legendEntryHeight = 0; // could be multi-line
double legendEntryMaxWidth = 0; // could be multi-line
for (Map.Entry<String, Rectangle2D> 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) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (pre-existing, carried over): Scatter entries under-advance by legendSeriesLineLength - BOX_SIZE (4px at defaults).

Legend_Marker.doPaint() draws both Line and Scatter entries' text at startx + getLegendSeriesLineLength() + padding (the marker is centered at lineLength / 2), but this method returns BOX_SIZE for Scatter because it only special-cases Line. So a Scatter entry actually occupies lineLength + padding + textWidth while its advance width claims BOX_SIZE + padding + textWidth.

The old single-row code had the same inconsistency (it also only checked == Line), so this PR faithfully preserves existing spacing. But now that the advance width also decides wrap points, a Scatter-heavy legend can pack a row a few px too tight and let the last entry's text touch the box border.

Suggested follow-up (would slightly change existing horizontal spacing for scatter charts, hence not snuck into this PR):

return (series.getLegendRenderType() == LegendRenderType.Line
        || series.getLegendRenderType() == LegendRenderType.Scatter)
    ? chart.getStyler().getLegendSeriesLineLength()
    : BOX_SIZE;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a87915. getLegendEntryMarkerWidth() now treats Scatter like Line (reserves the series-line length), matching where Legend_Marker paints the text, so the advance width and the wrap points are consistent for scatter charts.


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() {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (verified): a near-max-width row can still be truncated on the right edge.

The wrap threshold is keyed off the chart width, but OutsideS centers the legend box on the plot (xOffset = plotX + (plotW - boundsW)/2). Since the plot center sits right of the image center (the y-axis consumes left space), a row that packs close to maxRowWidth overflows the right edge.

Reproduced empirically (800px-wide chart, 14 series, sweeping name lengths):

nameLen=10 width=768.7 xOffset=38.8 rightEdge=807.5  → 7.5px truncated
nameLen=32 width=763.7 xOffset=41.3 rightEdge=805.0  → 5.0px truncated
nameLen=22 width=758.5 xOffset=43.9 rightEdge=802.4  → 2.4px truncated

The wrap threshold can't simply use the plot width instead — legend bounds are consumed during axis preparation, before the plot width is final (circular dependency). The clean fix is to clamp the computed xOffset in paint() so the box always stays within the image:

// after the switch statement, for horizontal layouts
xOffset = Math.min(xOffset, chart.getWidth() - bounds.getWidth() - LEGEND_MARGIN);
xOffset = Math.max(xOffset, LEGEND_MARGIN);

This keeps the plot-centered position in the common case and only nudges the box left when it would otherwise be cut off. The regression test should then also assert position (xOffset >= 0 && xOffset + width <= chartWidth), not just width.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a87915. Clamped xOffset for OutsideS into [LEGEND_MARGIN, chartWidth - boundsWidth - LEGEND_MARGIN] right after the position switch. RegressionTestIssue577 now also asserts the box sits fully within the image; it uses wide y-axis tick labels to push the plot right so the assertion fails by ~40px without the clamp (verified by temporarily disabling it).


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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public void doPaint(Graphics2D g) {
: RenderingHints.VALUE_ANTIALIAS_OFF);

Map<String, S> 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()) {
Expand All @@ -52,6 +58,16 @@ public void doPaint(Graphics2D g) {
Map<String, Rectangle2D> 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());
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ST extends Styler, S extends HorizontalBarSeries>
Expand Down Expand Up @@ -35,6 +34,12 @@ public void doPaint(Graphics2D g) {
: RenderingHints.VALUE_ANTIALIAS_OFF);

Map<String, S> 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()) {
Expand All @@ -47,7 +52,15 @@ public void doPaint(Graphics2D g) {
Map<String, Rectangle2D> 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);
Expand All @@ -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);
Expand Down
Loading
Loading