From 1fbbb69e7ba6ff4eb38104139289eaa2e3ebe7f9 Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Tue, 13 Oct 2026 05:07:22 -0700
Subject: [PATCH 1/2] Opt-in gradient fills for category and horizontal bars
CategoryStyler and HorizontalBarStyler add setBarFillGradientEnabled,
initialized false so existing charts keep the flat fill. When the flag
is on, PlotContent_Category and PlotContent_HorizontalBar fill each bar
with a non-cyclic GradientPaint from the series fill at the segment base
to fill.brighter() at the value end; negative bars reverse those ends,
coincident endpoints stay flat, and the Graphics2D paint from before the
gradient is restored. Legend_Marker uses that upward positive-bar
gradient only for a CategorySeries rendered as Bar, and
Legend_HorizontalBar uses a left-to-right positive-bar gradient; every
other swatch stays a flat fill. BarChart14 turns the option on for
positive and negative category bars, and BarFillGradientTest checks
rendered pixels for direction, stacking, clipping, log axes, legends,
non-bar series, and alpha.
Fixes #730
---
.../xchart/demo/charts/bar/BarChart14.java | 61 ++
.../chartpart/Legend_HorizontalBar.java | 21 +-
.../internal/chartpart/Legend_Marker.java | 40 +-
.../chartpart/PlotContent_Category.java | 61 +-
.../chartpart/PlotContent_HorizontalBar.java | 61 +-
.../knowm/xchart/style/CategoryStyler.java | 24 +
.../xchart/style/HorizontalBarStyler.java | 24 +
.../chartpart/BarFillGradientTest.java | 949 ++++++++++++++++++
8 files changed, 1232 insertions(+), 9 deletions(-)
create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart14.java
create mode 100644 xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java
diff --git a/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart14.java b/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart14.java
new file mode 100644
index 000000000..bc5ef6f0e
--- /dev/null
+++ b/xchart-demo/src/main/java/org/knowm/xchart/demo/charts/bar/BarChart14.java
@@ -0,0 +1,61 @@
+package org.knowm.xchart.demo.charts.bar;
+
+import java.util.Arrays;
+import org.knowm.xchart.CategoryChart;
+import org.knowm.xchart.CategoryChartBuilder;
+import org.knowm.xchart.SwingWrapper;
+import org.knowm.xchart.demo.charts.ExampleChart;
+import org.knowm.xchart.style.Styler.LegendPosition;
+
+/**
+ * Bar Chart with Gradient Fills
+ *
+ *
Demonstrates the following:
+ *
+ *
+ * - Opt-in bar gradient from the series fill color at the base to a brighter color at the value
+ *
- Positive and negative values, with the brighter end away from zero
+ *
- Legend swatches use the positive-bar gradient
+ *
+ */
+public class BarChart14 implements ExampleChart {
+
+ public static void main(String[] args) {
+
+ ExampleChart exampleChart = new BarChart14();
+ CategoryChart chart = exampleChart.getChart();
+ new SwingWrapper<>(chart).displayChart();
+ }
+
+ @Override
+ public CategoryChart getChart() {
+
+ // Create Chart
+ CategoryChart chart =
+ new CategoryChartBuilder()
+ .width(800)
+ .height(600)
+ .title(getClass().getSimpleName())
+ .xAxisTitle("Category")
+ .yAxisTitle("Value")
+ .build();
+
+ // Customize Chart
+ chart.getStyler().setLegendPosition(LegendPosition.InsideNW);
+ chart.getStyler().setBarFillGradientEnabled(true);
+
+ // Series
+ chart.addSeries(
+ "high", Arrays.asList("A", "B", "C", "D", "E"), Arrays.asList(50, -20, 70, -40, 30));
+ chart.addSeries(
+ "low", Arrays.asList("A", "B", "C", "D", "E"), Arrays.asList(20, -35, 25, -15, 45));
+
+ return chart;
+ }
+
+ @Override
+ public String getExampleChartName() {
+
+ return getClass().getSimpleName() + " - Bar Chart with Gradient Fills";
+ }
+}
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 33a886f09..a89fd50b9 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,6 +4,7 @@
import java.awt.geom.Rectangle2D;
import java.util.Map;
import org.knowm.xchart.HorizontalBarSeries;
+import org.knowm.xchart.style.HorizontalBarStyler;
import org.knowm.xchart.style.Styler;
public class Legend_HorizontalBar
@@ -34,6 +35,9 @@ public void doPaint(Graphics2D g) {
: RenderingHints.VALUE_ANTIALIAS_OFF);
Map map = chart.getSeriesMap();
+ boolean barFillGradient =
+ chart.getStyler() instanceof HorizontalBarStyler
+ && ((HorizontalBarStyler) chart.getStyler()).isBarFillGradientEnabled();
// 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.
@@ -62,10 +66,21 @@ public void doPaint(Graphics2D g) {
starty = cursor.y;
}
- // paint inner box
+ // paint inner box. Enabled charts use a left-to-right positive-bar gradient.
Shape rectSmall = new Rectangle2D.Double(startx, starty, BOX_SIZE, BOX_SIZE);
- g.setColor(series.getFillColor());
- g.fill(rectSmall);
+ Color fill = series.getFillColor();
+ if (barFillGradient && fill != null) {
+ Paint previousPaint = g.getPaint();
+ float baseX = (float) startx;
+ float valueX = (float) (startx + BOX_SIZE);
+ float y = (float) starty;
+ g.setPaint(new GradientPaint(baseX, y, fill, valueX, y, fill.brighter(), false));
+ g.fill(rectSmall);
+ g.setPaint(previousPaint);
+ } else {
+ g.setColor(fill);
+ g.fill(rectSmall);
+ }
// paint series text
double x = startx + BOX_SIZE + chart.getStyler().getLegendPadding();
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 e8768bb96..db4a58d56 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
@@ -5,8 +5,11 @@
import java.awt.geom.Path2D;
import java.awt.geom.Rectangle2D;
import java.util.Map;
+import org.knowm.xchart.CategorySeries;
+import org.knowm.xchart.CategorySeries.CategorySeriesRenderStyle;
import org.knowm.xchart.internal.chartpart.RenderableSeries.LegendRenderType;
import org.knowm.xchart.internal.series.MarkerSeries;
+import org.knowm.xchart.style.CategoryStyler;
import org.knowm.xchart.style.Styler;
import org.knowm.xchart.style.lines.SeriesLines;
@@ -118,8 +121,7 @@ public void doPaint(Graphics2D g) {
// paint inner box
Shape rectSmall = new Rectangle2D.Double(startx, boxStarty, BOX_SIZE, BOX_SIZE);
- g.setColor(series.getFillColor());
- g.fill(rectSmall);
+ fillLegendBox(g, series, rectSmall, startx, boxStarty);
// Draw outline
if (series.getLegendRenderType() != LegendRenderType.BoxNoOutline) {
@@ -180,6 +182,40 @@ public void doPaint(Graphics2D g) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldHint);
}
+ /**
+ * Category bars with the gradient enabled use a bottom-to-top positive-bar gradient inside the
+ * swatch. Every other legend entry, including non-bar category styles and charts that share this
+ * renderer, keeps the flat fill.
+ */
+ private void fillLegendBox(Graphics2D g, S series, Shape box, double x, double y) {
+
+ Color fill = series.getFillColor();
+ if (isCategoryBarGradient(series) && fill != null) {
+ Paint previousPaint = g.getPaint();
+ float baseX = (float) x;
+ float baseY = (float) (y + BOX_SIZE);
+ float valueY = (float) y;
+ g.setPaint(new GradientPaint(baseX, baseY, fill, baseX, valueY, fill.brighter(), false));
+ g.fill(box);
+ g.setPaint(previousPaint);
+ } else {
+ g.setColor(fill);
+ g.fill(box);
+ }
+ }
+
+ private boolean isCategoryBarGradient(S series) {
+
+ if (!(series instanceof CategorySeries) || !(axesChartStyler instanceof CategoryStyler)) {
+ return false;
+ }
+ if (!((CategoryStyler) axesChartStyler).isBarFillGradientEnabled()) {
+ return false;
+ }
+ return ((CategorySeries) series).getChartCategorySeriesRenderStyle().orElse(null)
+ == CategorySeriesRenderStyle.Bar;
+ }
+
@Override
public double getSeriesLegendRenderGraphicHeight(S series) {
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java
index a0133f310..ffaaf804f 100644
--- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java
@@ -287,8 +287,21 @@ public void doPaint(Graphics2D g) {
barPath.lineTo(xOffset, zeroOffset);
barPath.closePath();
- g.setColor(series.getFillColor());
- g.fill(barPath);
+ // Gradient runs from the segment base (fill) to the value end (brighter). Positive-only
+ // axes, including logarithmic axes, always use that direction. Negative bars flip it so
+ // the brighter end stays away from zero. Each stacked segment restarts at its own base.
+ boolean negativeBar = barExtendsNegative(chartForm, y);
+ double baseY = negativeBar ? yOffset : zeroOffset;
+ double valueY = negativeBar ? zeroOffset : yOffset;
+ fillBar(
+ g,
+ barPath,
+ series.getFillColor(),
+ stylerCategory.isBarFillGradientEnabled(),
+ xOffset,
+ baseY,
+ xOffset,
+ valueY);
// TODO maybe we want outlines of the bars?
// Legend markers now also draw the outline. It has been disabled for
@@ -783,6 +796,50 @@ private void drawStepBar(
drawStepBarLine(g, series, drawPath);
}
+ /** True when the bar grows toward more negative transformed values. */
+ private static boolean barExtendsNegative(int chartForm, double transformed) {
+
+ if (chartForm > 0) {
+ return false;
+ }
+ if (chartForm < 0) {
+ return true;
+ }
+ return transformed < 0.0;
+ }
+
+ /**
+ * Fills {@code bar} with {@code fill}, or with a non-cyclic gradient from {@code fill} at the
+ * base to {@code fill.brighter()} at the value end. Coincident endpoints keep the flat fill. The
+ * paint in effect before a gradient fill is restored.
+ */
+ private static void fillBar(
+ Graphics2D g,
+ Shape bar,
+ Color fill,
+ boolean gradient,
+ double baseX,
+ double baseY,
+ double valueX,
+ double valueY) {
+
+ if (gradient && fill != null) {
+ float x1 = (float) baseX;
+ float y1 = (float) baseY;
+ float x2 = (float) valueX;
+ float y2 = (float) valueY;
+ if (x1 != x2 || y1 != y2) {
+ Paint previousPaint = g.getPaint();
+ g.setPaint(new GradientPaint(x1, y1, fill, x2, y2, fill.brighter(), false));
+ g.fill(bar);
+ g.setPaint(previousPaint);
+ return;
+ }
+ }
+ g.setColor(fill);
+ g.fill(bar);
+ }
+
private void drawLabels(
Graphics2D g,
double next,
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java
index 78792c525..fa5107f7d 100644
--- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java
@@ -165,8 +165,21 @@ public void doPaint(Graphics2D g) {
barPath.lineTo(zeroOffset, yOffset + barHeight);
barPath.closePath();
- g.setColor(series.getFillColor());
- g.fill(barPath);
+ // Same base-to-value gradient as category bars, along X. xOffset is the right edge and
+ // zeroOffset the left, including for negative bars. Positive-only axes (including a
+ // logarithmic axis) keep the brighter end at the value; negative bars flip it.
+ boolean negativeBar = barExtendsNegative(chartForm, x);
+ double baseX = negativeBar ? xOffset : zeroOffset;
+ double valueX = negativeBar ? zeroOffset : xOffset;
+ fillBar(
+ g,
+ barPath,
+ series.getFillColor(),
+ styler.isBarFillGradientEnabled(),
+ baseX,
+ yOffset,
+ valueX,
+ yOffset);
if (styler.isLabelsVisible() && next != null) {
drawLabels(g, next, xOffset, yOffset, zeroOffset, barHeight, series.getFillColor());
@@ -201,6 +214,50 @@ public void doPaint(Graphics2D g) {
}
}
+ /** True when the bar grows toward more negative transformed values. */
+ private static boolean barExtendsNegative(int chartForm, double transformed) {
+
+ if (chartForm > 0) {
+ return false;
+ }
+ if (chartForm < 0) {
+ return true;
+ }
+ return transformed < 0.0;
+ }
+
+ /**
+ * Fills {@code bar} with {@code fill}, or with a non-cyclic gradient from {@code fill} at the
+ * base to {@code fill.brighter()} at the value end. Coincident endpoints keep the flat fill. The
+ * paint in effect before a gradient fill is restored.
+ */
+ private static void fillBar(
+ Graphics2D g,
+ Shape bar,
+ Color fill,
+ boolean gradient,
+ double baseX,
+ double baseY,
+ double valueX,
+ double valueY) {
+
+ if (gradient && fill != null) {
+ float x1 = (float) baseX;
+ float y1 = (float) baseY;
+ float x2 = (float) valueX;
+ float y2 = (float) valueY;
+ if (x1 != x2 || y1 != y2) {
+ Paint previousPaint = g.getPaint();
+ g.setPaint(new GradientPaint(x1, y1, fill, x2, y2, fill.brighter(), false));
+ g.fill(bar);
+ g.setPaint(previousPaint);
+ return;
+ }
+ }
+ g.setColor(fill);
+ g.fill(bar);
+ }
+
private void drawLabels(
Graphics2D g,
Number next,
diff --git a/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java b/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java
index 2929c09e1..fad36c6e3 100644
--- a/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java
+++ b/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java
@@ -25,6 +25,9 @@ public class CategoryStyler extends AxesChartStyler {
private Color labelsFontColorAutomaticLight;
private Color labelsFontColorAutomaticDark;
+ /** Two-color bar gradient; flat fills stay the default. */
+ private boolean isBarFillGradientEnabled;
+
/** Constructor */
public CategoryStyler() {
@@ -48,6 +51,7 @@ protected void setAllStyles() {
isLabelsFontColorAutomaticEnabled = theme.isLabelsFontColorAutomaticEnabled();
labelsFontColorAutomaticLight = theme.getLabelsFontColorAutomaticLight();
labelsFontColorAutomaticDark = theme.getLabelsFontColorAutomaticDark();
+ isBarFillGradientEnabled = false;
}
public CategorySeriesRenderStyle getDefaultSeriesRenderStyle() {
@@ -258,6 +262,26 @@ public CategoryStyler setLabelsFontColorAutomaticDark(Color labelsFontColorAutom
return this;
}
+ public boolean isBarFillGradientEnabled() {
+
+ return isBarFillGradientEnabled;
+ }
+
+ /**
+ * Enables a two-color gradient fill for category bars. The series fill color is painted at the
+ * bar base and {@code fill.brighter()} at the value end. Negative bars reverse that direction so
+ * the brighter end stays at the value, away from zero. Disabled by default, which keeps the flat
+ * series fill. A bar whose base and value endpoints coincide also stays flat.
+ *
+ * @param barFillGradientEnabled {@code true} to paint bars with the gradient
+ * @return this styler
+ */
+ public CategoryStyler setBarFillGradientEnabled(boolean barFillGradientEnabled) {
+
+ this.isBarFillGradientEnabled = barFillGradientEnabled;
+ return this;
+ }
+
/**
* Set the theme the styler should use
*
diff --git a/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java b/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java
index 3e4417265..9b3ed2934 100644
--- a/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java
+++ b/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java
@@ -18,6 +18,9 @@ public class HorizontalBarStyler extends AxesChartStyler {
private Color labelsFontColorAutomaticLight;
private Color labelsFontColorAutomaticDark;
+ /** Two-color bar gradient; flat fills stay the default. */
+ private boolean isBarFillGradientEnabled;
+
/** Constructor */
public HorizontalBarStyler() {
@@ -38,6 +41,7 @@ protected void setAllStyles() {
isLabelsFontColorAutomaticEnabled = theme.isLabelsFontColorAutomaticEnabled();
labelsFontColorAutomaticLight = theme.getLabelsFontColorAutomaticLight();
labelsFontColorAutomaticDark = theme.getLabelsFontColorAutomaticDark();
+ isBarFillGradientEnabled = false;
}
public double getAvailableSpaceFill() {
@@ -178,6 +182,26 @@ public HorizontalBarStyler setLabelsFontColorAutomaticDark(Color labelsFontColor
return this;
}
+ public boolean isBarFillGradientEnabled() {
+
+ return isBarFillGradientEnabled;
+ }
+
+ /**
+ * Enables a two-color gradient fill for horizontal bars. The series fill color is painted at the
+ * bar base and {@code fill.brighter()} at the value end. Negative bars reverse that direction so
+ * the brighter end stays at the value, away from zero. Disabled by default, which keeps the flat
+ * series fill. A bar whose base and value endpoints coincide also stays flat.
+ *
+ * @param barFillGradientEnabled {@code true} to paint bars with the gradient
+ * @return this styler
+ */
+ public HorizontalBarStyler setBarFillGradientEnabled(boolean barFillGradientEnabled) {
+
+ this.isBarFillGradientEnabled = barFillGradientEnabled;
+ return this;
+ }
+
/**
* Set the theme the styler should use
*
diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java
new file mode 100644
index 000000000..30f181a3e
--- /dev/null
+++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java
@@ -0,0 +1,949 @@
+package org.knowm.xchart.internal.chartpart;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Rectangle;
+import java.awt.image.BufferedImage;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.IntPredicate;
+import org.junit.jupiter.api.Test;
+import org.knowm.xchart.BitmapEncoder;
+import org.knowm.xchart.CategoryChart;
+import org.knowm.xchart.CategoryChartBuilder;
+import org.knowm.xchart.CategorySeries;
+import org.knowm.xchart.CategorySeries.CategorySeriesRenderStyle;
+import org.knowm.xchart.HorizontalBarChart;
+import org.knowm.xchart.HorizontalBarChartBuilder;
+import org.knowm.xchart.XYChart;
+import org.knowm.xchart.XYChartBuilder;
+import org.knowm.xchart.XYSeries;
+import org.knowm.xchart.XYSeries.XYSeriesRenderStyle;
+import org.knowm.xchart.style.AxesChartStyler;
+import org.knowm.xchart.style.CategoryStyler;
+import org.knowm.xchart.style.HorizontalBarStyler;
+import org.knowm.xchart.style.Styler;
+import org.knowm.xchart.style.markers.SeriesMarkers;
+import org.knowm.xchart.style.theme.GGPlot2Theme;
+import org.knowm.xchart.style.theme.XChartTheme;
+
+/**
+ * Opt-in bar gradients: flat by default, base-to-value when enabled, including legend swatches.
+ * Samples interior pixels instead of golden images.
+ */
+public class BarFillGradientTest {
+
+ private static final int WIDTH = 640;
+ private static final int HEIGHT = 420;
+
+ private static final Color FILL = new Color(40, 70, 160);
+ private static final Color FILL_B = new Color(170, 50, 40);
+ private static final Color AREA = new Color(140, 90, 20);
+ private static final Color STEP = new Color(90, 40, 140);
+ private static final Color LINE = new Color(220, 100, 0);
+ private static final Color LABEL = new Color(210, 0, 200);
+ private static final Color ERROR = new Color(0, 150, 150);
+ private static final Color LEGEND_TEXT = new Color(12, 34, 56);
+ private static final Color ALPHA_FILL = new Color(80, 40, 160, 128);
+
+ private static final int BRIGHTNESS_DELTA = 20;
+
+ @Test
+ public void stylersDefaultToFalseAndResetWithTheTheme() {
+
+ CategoryChart category = new CategoryChartBuilder().width(200).height(150).build();
+ CategoryStyler categoryStyler = category.getStyler();
+ assertThat(categoryStyler.isBarFillGradientEnabled()).isFalse();
+ assertThat(categoryStyler.setBarFillGradientEnabled(true)).isSameAs(categoryStyler);
+ assertThat(categoryStyler.isBarFillGradientEnabled()).isTrue();
+ categoryStyler.setLabelsVisible(true);
+ categoryStyler.setTheme(new GGPlot2Theme());
+ assertThat(categoryStyler.isBarFillGradientEnabled()).isFalse();
+ assertThat(categoryStyler.isLabelsVisible()).isFalse();
+ assertThat(categoryStyler.setBarFillGradientEnabled(false).isBarFillGradientEnabled())
+ .isFalse();
+
+ HorizontalBarChart horizontal = new HorizontalBarChartBuilder().width(200).height(150).build();
+ HorizontalBarStyler horizontalStyler = horizontal.getStyler();
+ assertThat(horizontalStyler.isBarFillGradientEnabled()).isFalse();
+ assertThat(horizontalStyler.setBarFillGradientEnabled(true)).isSameAs(horizontalStyler);
+ assertThat(horizontalStyler.isBarFillGradientEnabled()).isTrue();
+ horizontalStyler.setLabelsVisible(true);
+ horizontalStyler.setTheme(new XChartTheme());
+ assertThat(horizontalStyler.isBarFillGradientEnabled()).isFalse();
+ assertThat(horizontalStyler.isLabelsVisible()).isFalse();
+ }
+
+ @Test
+ public void barsAreFlatByDefaultAndAfterDisabling() {
+
+ CategoryChart category = categoryChart();
+ category.getStyler().setYAxisMin(0.0);
+ category.getStyler().setYAxisMax(10.0);
+ category.addSeries("a", Arrays.asList("c"), Arrays.asList(10)).setFillColor(FILL);
+
+ BufferedImage flat = BitmapEncoder.getBufferedImage(category);
+ Rectangle flatBox = onlyBox(flat, FILL, false);
+ assertFlatVertical(flat, flatBox, FILL);
+
+ category.getStyler().setBarFillGradientEnabled(true);
+ BufferedImage gradient = BitmapEncoder.getBufferedImage(category);
+ Rectangle gradientBox = onlyBox(gradient, FILL, true);
+ assertClose(flatBox, gradientBox);
+ assertVerticalGradient(gradient, gradientBox, FILL, true);
+
+ category.getStyler().setBarFillGradientEnabled(false);
+ BufferedImage disabled = BitmapEncoder.getBufferedImage(category);
+ assertFlatVertical(disabled, onlyBox(disabled, FILL, false), FILL);
+
+ HorizontalBarChart horizontal = horizontalChart();
+ horizontal.getStyler().setXAxisMin(0.0);
+ horizontal.getStyler().setXAxisMax(10.0);
+ horizontal.addSeries("a", Arrays.asList(10), Arrays.asList("c")).setFillColor(FILL);
+
+ BufferedImage flatH = BitmapEncoder.getBufferedImage(horizontal);
+ assertFlatHorizontal(flatH, onlyBox(flatH, FILL, false), FILL);
+
+ horizontal.getStyler().setBarFillGradientEnabled(true);
+ BufferedImage gradientH = BitmapEncoder.getBufferedImage(horizontal);
+ assertHorizontalGradient(gradientH, onlyBox(gradientH, FILL, true), FILL, true);
+
+ horizontal.getStyler().setBarFillGradientEnabled(false);
+ BufferedImage disabledH = BitmapEncoder.getBufferedImage(horizontal);
+ assertFlatHorizontal(disabledH, onlyBox(disabledH, FILL, false), FILL);
+ }
+
+ @Test
+ public void negativeBarsAreBrighterAwayFromZero() {
+
+ CategoryChart span = categoryChart();
+ span.getStyler().setBarFillGradientEnabled(true);
+ span.getStyler().setYAxisMin(-10.0);
+ span.getStyler().setYAxisMax(10.0);
+ span.addSeries("a", Arrays.asList("p", "n"), Arrays.asList(8, -8)).setFillColor(FILL);
+ BufferedImage spanImage = BitmapEncoder.getBufferedImage(span);
+ List spanBoxes = boxes(spanImage, null, FILL, true);
+ assertThat(spanBoxes).hasSize(2);
+ assertVerticalGradient(spanImage, spanBoxes.get(0), FILL, true);
+ assertVerticalGradient(spanImage, spanBoxes.get(1), FILL, false);
+
+ CategoryChart negativeOnly = categoryChart();
+ negativeOnly.getStyler().setBarFillGradientEnabled(true);
+ negativeOnly.getStyler().setYAxisMin(-30.0);
+ negativeOnly.getStyler().setYAxisMax(-1.0);
+ negativeOnly.addSeries("a", Arrays.asList("n"), Arrays.asList(-20)).setFillColor(FILL);
+ BufferedImage negativeImage = BitmapEncoder.getBufferedImage(negativeOnly);
+ assertVerticalGradient(negativeImage, onlyBox(negativeImage, FILL, true), FILL, false);
+
+ HorizontalBarChart spanH = horizontalChart();
+ spanH.getStyler().setBarFillGradientEnabled(true);
+ spanH.getStyler().setXAxisMin(-10.0);
+ spanH.getStyler().setXAxisMax(10.0);
+ spanH.addSeries("a", Arrays.asList(8, -8), Arrays.asList("p", "n")).setFillColor(FILL);
+ BufferedImage spanHImage = BitmapEncoder.getBufferedImage(spanH);
+ List spanHBoxes = boxes(spanHImage, null, FILL, true);
+ spanHBoxes.sort(Comparator.comparingInt((Rectangle r) -> r.y).thenComparingInt(r -> r.x));
+ assertThat(spanHBoxes).hasSize(2);
+ assertHorizontalGradient(spanHImage, spanHBoxes.get(0), FILL, true);
+ assertHorizontalGradient(spanHImage, spanHBoxes.get(1), FILL, false);
+
+ HorizontalBarChart negativeH = horizontalChart();
+ negativeH.getStyler().setBarFillGradientEnabled(true);
+ negativeH.getStyler().setXAxisMin(-30.0);
+ negativeH.getStyler().setXAxisMax(-1.0);
+ negativeH.addSeries("a", Arrays.asList(-20), Arrays.asList("n")).setFillColor(FILL);
+ BufferedImage negativeHImage = BitmapEncoder.getBufferedImage(negativeH);
+ assertHorizontalGradient(negativeHImage, onlyBox(negativeHImage, FILL, true), FILL, false);
+ }
+
+ @Test
+ public void groupedAndStackedSegmentsKeepGeometryAndDirection() {
+
+ CategoryChart groupedOff = grouped(false);
+ CategoryChart groupedOn = grouped(true);
+ BufferedImage groupedOffImage = BitmapEncoder.getBufferedImage(groupedOff);
+ BufferedImage groupedOnImage = BitmapEncoder.getBufferedImage(groupedOn);
+ assertSameYRange(groupedOff, groupedOn);
+ Rectangle groupedAOff = onlyBox(groupedOffImage, FILL, false);
+ Rectangle groupedBOff = onlyBox(groupedOffImage, FILL_B, false);
+ Rectangle groupedAOn = onlyBox(groupedOnImage, FILL, true);
+ Rectangle groupedBOn = onlyBox(groupedOnImage, FILL_B, true);
+ assertClose(groupedAOff, groupedAOn);
+ assertClose(groupedBOff, groupedBOn);
+ assertFlatVertical(groupedOffImage, groupedAOff, FILL);
+ assertVerticalGradient(groupedOnImage, groupedAOn, FILL, true);
+ assertVerticalGradient(groupedOnImage, groupedBOn, FILL_B, true);
+
+ CategoryChart stackedOff = stacked(false);
+ CategoryChart stackedOn = stacked(true);
+ BufferedImage stackedOffImage = BitmapEncoder.getBufferedImage(stackedOff);
+ BufferedImage stackedOnImage = BitmapEncoder.getBufferedImage(stackedOn);
+ assertSameYRange(stackedOff, stackedOn);
+
+ List aOff = boxes(stackedOffImage, null, FILL, false);
+ List bOff = boxes(stackedOffImage, null, FILL_B, false);
+ List aOn = boxes(stackedOnImage, null, FILL, true);
+ List bOn = boxes(stackedOnImage, null, FILL_B, true);
+ assertThat(aOn).hasSize(2);
+ assertThat(bOn).hasSize(2);
+ assertClose(aOff.get(0), aOn.get(0));
+ assertClose(aOff.get(1), aOn.get(1));
+ assertClose(bOff.get(0), bOn.get(0));
+ assertClose(bOff.get(1), bOn.get(1));
+
+ // Left category is the positive stack (B on top of A); right category is the negative stack.
+ assertVerticalGradient(stackedOnImage, aOn.get(0), FILL, true);
+ assertVerticalGradient(stackedOnImage, bOn.get(0), FILL_B, true);
+ assertThat(Math.abs(aOn.get(0).y - (bOn.get(0).y + bOn.get(0).height))).isLessThanOrEqualTo(2);
+ assertVerticalGradient(stackedOnImage, aOn.get(1), FILL, false);
+ assertVerticalGradient(stackedOnImage, bOn.get(1), FILL_B, false);
+ assertThat(Math.abs(bOn.get(1).y - (aOn.get(1).y + aOn.get(1).height))).isLessThanOrEqualTo(2);
+ assertFlatVertical(stackedOffImage, aOff.get(0), FILL);
+ assertFlatVertical(stackedOffImage, bOff.get(1), FILL_B);
+ }
+
+ @Test
+ public void zeroLengthNullClippingAndLogAxisRender() {
+
+ CategoryChart sparse = categoryChart();
+ sparse.getStyler().setBarFillGradientEnabled(true);
+ sparse.getStyler().setYAxisMin(-2.0);
+ sparse.getStyler().setYAxisMax(10.0);
+ sparse
+ .addSeries("a", Arrays.asList("z", "missing", "value"), Arrays.asList(0, null, 8))
+ .setFillColor(FILL);
+ BufferedImage sparseImage = BitmapEncoder.getBufferedImage(sparse);
+ List sparseBoxes = boxes(sparseImage, null, FILL, true);
+ assertThat(sparseBoxes).hasSize(1);
+ assertVerticalGradient(sparseImage, sparseBoxes.get(0), FILL, true);
+
+ CategoryChart clipped = categoryChart();
+ clipped.getStyler().setBarFillGradientEnabled(true);
+ clipped.getStyler().setYAxisMin(4.0);
+ clipped.getStyler().setYAxisMax(8.0);
+ clipped
+ .addSeries("a", Arrays.asList("below", "inside", "above"), Arrays.asList(1, 6, 12))
+ .setFillColor(FILL);
+ BufferedImage clippedImage = BitmapEncoder.getBufferedImage(clipped);
+ List clippedBoxes = boxes(clippedImage, null, FILL, true);
+ assertThat(clippedBoxes).hasSize(2);
+ assertVerticalGradient(clippedImage, clippedBoxes.get(0), FILL, true);
+ assertVerticalGradient(clippedImage, clippedBoxes.get(1), FILL, true);
+ assertThat(clippedBoxes.get(1).height).isGreaterThan(clippedBoxes.get(0).height);
+
+ CategoryChart logOff = logCategory(false);
+ CategoryChart logOn = logCategory(true);
+ BufferedImage logOffImage = BitmapEncoder.getBufferedImage(logOff);
+ BufferedImage logOnImage = BitmapEncoder.getBufferedImage(logOn);
+ assertSameYRange(logOff, logOn);
+ assertThat(yAxis(logOn).getMin()).isEqualTo(0.1);
+ assertThat(yAxis(logOn).getMax()).isEqualTo(10.0);
+ List logOffBoxes = boxes(logOffImage, null, FILL, false);
+ List logOnBoxes = boxes(logOnImage, null, FILL, true);
+ assertThat(logOnBoxes).hasSize(2);
+ assertClose(logOffBoxes.get(0), logOnBoxes.get(0));
+ assertClose(logOffBoxes.get(1), logOnBoxes.get(1));
+ // 0.5 transforms to a negative log but still grows up from the positive axis minimum.
+ assertVerticalGradient(logOnImage, logOnBoxes.get(0), FILL, true);
+ assertVerticalGradient(logOnImage, logOnBoxes.get(1), FILL, true);
+
+ HorizontalBarChart sparseH = horizontalChart();
+ sparseH.getStyler().setBarFillGradientEnabled(true);
+ sparseH.getStyler().setXAxisMin(-2.0);
+ sparseH.getStyler().setXAxisMax(10.0);
+ sparseH
+ .addSeries("a", Arrays.asList(0, null, 8), Arrays.asList("z", "missing", "value"))
+ .setFillColor(FILL);
+ BufferedImage sparseHImage = BitmapEncoder.getBufferedImage(sparseH);
+ List sparseHBoxes = boxes(sparseHImage, null, FILL, true);
+ sparseHBoxes.sort(Comparator.comparingInt((Rectangle r) -> r.y));
+ assertThat(sparseHBoxes).hasSize(1);
+ assertHorizontalGradient(sparseHImage, sparseHBoxes.get(0), FILL, true);
+
+ HorizontalBarChart clippedH = horizontalChart();
+ clippedH.getStyler().setBarFillGradientEnabled(true);
+ clippedH.getStyler().setXAxisMin(4.0);
+ clippedH.getStyler().setXAxisMax(8.0);
+ clippedH
+ .addSeries("a", Arrays.asList(1, 6, 12), Arrays.asList("below", "inside", "above"))
+ .setFillColor(FILL);
+ BufferedImage clippedHImage = BitmapEncoder.getBufferedImage(clippedH);
+ List clippedHBoxes = boxes(clippedHImage, null, FILL, true);
+ clippedHBoxes.sort(Comparator.comparingInt((Rectangle r) -> r.y));
+ assertThat(clippedHBoxes).hasSize(2);
+ assertHorizontalGradient(clippedHImage, clippedHBoxes.get(0), FILL, true);
+ assertHorizontalGradient(clippedHImage, clippedHBoxes.get(1), FILL, true);
+ assertThat(clippedHBoxes.get(1).width).isGreaterThan(clippedHBoxes.get(0).width);
+
+ HorizontalBarChart logHOff = logHorizontal(false);
+ HorizontalBarChart logHOn = logHorizontal(true);
+ BufferedImage logHOffImage = BitmapEncoder.getBufferedImage(logHOff);
+ BufferedImage logHOnImage = BitmapEncoder.getBufferedImage(logHOn);
+ assertSameXRange(logHOff, logHOn);
+ assertThat(xAxis(logHOn).getMin()).isEqualTo(0.1);
+ assertThat(xAxis(logHOn).getMax()).isEqualTo(10.0);
+ List logHOffBoxes = boxes(logHOffImage, null, FILL, false);
+ List logHOnBoxes = boxes(logHOnImage, null, FILL, true);
+ logHOffBoxes.sort(Comparator.comparingInt((Rectangle r) -> r.y));
+ logHOnBoxes.sort(Comparator.comparingInt((Rectangle r) -> r.y));
+ assertThat(logHOnBoxes).hasSize(2);
+ assertClose(logHOffBoxes.get(0), logHOnBoxes.get(0));
+ assertClose(logHOffBoxes.get(1), logHOnBoxes.get(1));
+ assertHorizontalGradient(logHOnImage, logHOnBoxes.get(0), FILL, true);
+ assertHorizontalGradient(logHOnImage, logHOnBoxes.get(1), FILL, true);
+ }
+
+ @Test
+ public void legendSwatchesUseThePositiveBarGradient() {
+
+ for (Styler.LegendLayout layout : Styler.LegendLayout.values()) {
+ CategoryChart enabled = legendCategory(true, layout);
+ BufferedImage enabledImage = BitmapEncoder.getBufferedImage(enabled);
+ Rectangle swatch = onlyBox(enabledImage, legendRegion(enabled), FILL, true);
+ assertThat(swatch.width).isBetween(14, 24);
+ assertThat(swatch.height).isBetween(14, 24);
+ assertVerticalGradient(enabledImage, swatch, FILL, true);
+
+ CategoryChart disabled = legendCategory(false, layout);
+ BufferedImage disabledImage = BitmapEncoder.getBufferedImage(disabled);
+ assertFlatVertical(
+ disabledImage, onlyBox(disabledImage, legendRegion(disabled), FILL, false), FILL);
+
+ HorizontalBarChart enabledH = legendHorizontal(true, layout);
+ BufferedImage enabledHImage = BitmapEncoder.getBufferedImage(enabledH);
+ Rectangle swatchH = onlyBox(enabledHImage, legendRegion(enabledH), FILL, true);
+ assertThat(swatchH.width).isBetween(14, 24);
+ assertThat(swatchH.height).isBetween(14, 24);
+ assertHorizontalGradient(enabledHImage, swatchH, FILL, true);
+
+ HorizontalBarChart disabledH = legendHorizontal(false, layout);
+ BufferedImage disabledHImage = BitmapEncoder.getBufferedImage(disabledH);
+ assertFlatHorizontal(
+ disabledHImage, onlyBox(disabledHImage, legendRegion(disabledH), FILL, false), FILL);
+ }
+ }
+
+ @Test
+ public void nonBarSeriesAndXyAreaStayFlat() {
+
+ CategoryChart mixed = categoryChart();
+ mixed.getStyler().setBarFillGradientEnabled(true);
+ mixed.getStyler().setLegendVisible(true);
+ mixed.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ mixed.getStyler().setYAxisMin(0.0);
+ mixed.getStyler().setYAxisMax(10.0);
+
+ CategorySeries area = mixed.addSeries("area", Arrays.asList("a", "b"), Arrays.asList(6, 7));
+ area.setChartCategorySeriesRenderStyle(CategorySeriesRenderStyle.Area);
+ area.setFillColor(AREA);
+ area.setLineColor(Color.BLACK);
+ area.setMarker(SeriesMarkers.NONE);
+
+ CategorySeries step = mixed.addSeries("step", Arrays.asList("a", "b"), Arrays.asList(5, 8));
+ step.setChartCategorySeriesRenderStyle(CategorySeriesRenderStyle.SteppedBar);
+ step.setFillColor(STEP);
+ step.setLineColor(Color.BLACK);
+
+ CategorySeries line = mixed.addSeries("line", Arrays.asList("a", "b"), Arrays.asList(4, 6));
+ line.setChartCategorySeriesRenderStyle(CategorySeriesRenderStyle.Line);
+ line.setLineColor(LINE);
+ line.setMarker(SeriesMarkers.NONE);
+
+ CategorySeries bar = mixed.addSeries("bar", Arrays.asList("a", "b"), Arrays.asList(7, 7));
+ bar.setChartCategorySeriesRenderStyle(CategorySeriesRenderStyle.Bar);
+ bar.setFillColor(FILL);
+
+ BufferedImage image = BitmapEncoder.getBufferedImage(mixed);
+ assertSolid(image, AREA);
+ assertSolid(image, STEP);
+ assertSolid(image, LINE);
+ assertVerticalGradient(image, largestBox(image, null, FILL, true), FILL, true);
+
+ Rectangle legend = legendRegion(mixed);
+ assertVerticalGradient(image, onlyBox(image, legend, FILL, true), FILL, true);
+ assertFlatVertical(image, onlyBox(image, legend, STEP, false), STEP);
+ assertThat(boxes(image, legend, AREA, false)).isEmpty();
+ assertThat(boxes(image, legend, LINE, true)).isEmpty();
+
+ XYChart xy = new XYChartBuilder().width(WIDTH).height(HEIGHT).build();
+ prepareAxes(xy.getStyler());
+ xy.getStyler().setLegendVisible(true);
+ xy.getStyler().setXAxisMin(0.0);
+ xy.getStyler().setXAxisMax(10.0);
+ xy.getStyler().setYAxisMin(0.0);
+ xy.getStyler().setYAxisMax(10.0);
+ XYSeries xyArea = xy.addSeries("area", new double[] {0, 5, 10}, new double[] {4, 8, 6});
+ xyArea.setXYSeriesRenderStyle(XYSeriesRenderStyle.Area);
+ xyArea.setFillColor(AREA);
+ xyArea.setLineColor(Color.BLACK);
+ xyArea.setMarker(SeriesMarkers.NONE);
+ BufferedImage xyImage = BitmapEncoder.getBufferedImage(xy);
+ assertSolid(xyImage, AREA);
+ assertThat(boxes(xyImage, legendRegion(xy), AREA, true)).isEmpty();
+ }
+
+ @Test
+ public void paintAfterGradientKeepsSolidColors() {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(true);
+ chart.getStyler().setLegendVisible(true);
+ chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ chart.getStyler().setChartFontColor(LEGEND_TEXT);
+ chart.getStyler().setLegendFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
+ chart.getStyler().setLabelsVisible(true);
+ chart.getStyler().setLabelsFontColorAutomaticEnabled(false);
+ chart.getStyler().setLabelsFontColor(LABEL);
+ chart.getStyler().setLabelsFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
+ chart.getStyler().setYAxisDecimalPattern("0");
+ chart.getStyler().setErrorBarsColor(ERROR);
+ chart.getStyler().setErrorBarsColorSeriesColor(false);
+ chart.getStyler().setYAxisMin(0.0);
+ chart.getStyler().setYAxisMax(10.0);
+
+ CategorySeries bar =
+ chart.addSeries("bar", Arrays.asList("a", "b"), Arrays.asList(6, 6), Arrays.asList(1, 1));
+ bar.setFillColor(FILL);
+ bar.setChartCategorySeriesRenderStyle(CategorySeriesRenderStyle.Bar);
+
+ CategorySeries line = chart.addSeries("line", Arrays.asList("a", "b"), Arrays.asList(3, 5));
+ line.setChartCategorySeriesRenderStyle(CategorySeriesRenderStyle.Line);
+ line.setLineColor(LINE);
+ line.setMarker(SeriesMarkers.NONE);
+
+ BufferedImage image = BitmapEncoder.getBufferedImage(chart);
+ assertVerticalGradient(image, largestBox(image, null, FILL, true), FILL, true);
+ assertThat(countNear(image, null, LABEL, 2)).isGreaterThan(5);
+ assertThat(countNear(image, null, ERROR, 2)).isGreaterThan(8);
+ assertThat(countNear(image, legendRegion(chart), LEGEND_TEXT, 2)).isGreaterThan(5);
+ assertSolid(image, LINE);
+
+ HorizontalBarChart horizontal = horizontalChart();
+ horizontal.getStyler().setBarFillGradientEnabled(true);
+ horizontal.getStyler().setLegendVisible(true);
+ horizontal.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ horizontal.getStyler().setChartFontColor(LEGEND_TEXT);
+ horizontal.getStyler().setLegendFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
+ horizontal.getStyler().setLabelsVisible(true);
+ horizontal.getStyler().setLabelsFontColorAutomaticEnabled(false);
+ horizontal.getStyler().setLabelsFontColor(LABEL);
+ horizontal.getStyler().setLabelsFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
+ horizontal.getStyler().setXAxisDecimalPattern("0");
+ horizontal.getStyler().setXAxisMin(0.0);
+ horizontal.getStyler().setXAxisMax(10.0);
+ horizontal.addSeries("first", Arrays.asList(6), Arrays.asList("c")).setFillColor(FILL);
+ horizontal.addSeries("later", Arrays.asList(8), Arrays.asList("c")).setFillColor(FILL_B);
+
+ BufferedImage horizontalImage = BitmapEncoder.getBufferedImage(horizontal);
+ assertHorizontalGradient(
+ horizontalImage, largestBox(horizontalImage, null, FILL, true), FILL, true);
+ assertHorizontalGradient(
+ horizontalImage, largestBox(horizontalImage, null, FILL_B, true), FILL_B, true);
+ assertThat(countNear(horizontalImage, null, LABEL, 2)).isGreaterThan(5);
+ assertThat(countNear(horizontalImage, legendRegion(horizontal), LEGEND_TEXT, 2))
+ .isGreaterThan(5);
+ }
+
+ @Test
+ public void alphaFillIsPreserved() {
+
+ CategoryChart category = categoryChart();
+ category.getStyler().setBarFillGradientEnabled(true);
+ category.getStyler().setYAxisMin(0.0);
+ category.getStyler().setYAxisMax(10.0);
+ category.addSeries("a", Arrays.asList("c"), Arrays.asList(10)).setFillColor(ALPHA_FILL);
+ BufferedImage image = BitmapEncoder.getBufferedImage(category);
+ assertAlphaGradient(image, alphaBox(image), true);
+
+ HorizontalBarChart horizontal = horizontalChart();
+ horizontal.getStyler().setBarFillGradientEnabled(true);
+ horizontal.getStyler().setXAxisMin(0.0);
+ horizontal.getStyler().setXAxisMax(10.0);
+ horizontal.addSeries("a", Arrays.asList(10), Arrays.asList("c")).setFillColor(ALPHA_FILL);
+ BufferedImage horizontalImage = BitmapEncoder.getBufferedImage(horizontal);
+ assertAlphaGradient(horizontalImage, alphaBox(horizontalImage), false);
+ }
+
+ private static CategoryChart grouped(boolean gradient) {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setYAxisMin(0.0);
+ chart.getStyler().setYAxisMax(10.0);
+ chart.addSeries("a", Arrays.asList("c"), Arrays.asList(6)).setFillColor(FILL);
+ chart.addSeries("b", Arrays.asList("c"), Arrays.asList(8)).setFillColor(FILL_B);
+ return chart;
+ }
+
+ private static CategoryChart stacked(boolean gradient) {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setStacked(true);
+ chart.addSeries("a", Arrays.asList("p", "n"), Arrays.asList(4, -3)).setFillColor(FILL);
+ chart.addSeries("b", Arrays.asList("p", "n"), Arrays.asList(5, -2)).setFillColor(FILL_B);
+ return chart;
+ }
+
+ private static CategoryChart logCategory(boolean gradient) {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setYAxisLogarithmic(true);
+ chart.getStyler().setYAxisMin(0.1);
+ chart.getStyler().setYAxisMax(10.0);
+ chart
+ .addSeries("a", Arrays.asList("small", "min", "large"), Arrays.asList(0.5, 0.1, 5))
+ .setFillColor(FILL);
+ return chart;
+ }
+
+ private static HorizontalBarChart logHorizontal(boolean gradient) {
+
+ HorizontalBarChart chart = horizontalChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setXAxisLogarithmic(true);
+ chart.getStyler().setXAxisMin(0.1);
+ chart.getStyler().setXAxisMax(10.0);
+ chart
+ .addSeries("a", Arrays.asList(0.5, 0.1, 5), Arrays.asList("small", "min", "large"))
+ .setFillColor(FILL);
+ return chart;
+ }
+
+ private static CategoryChart legendCategory(boolean gradient, Styler.LegendLayout layout) {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setLegendVisible(true);
+ chart.getStyler().setLegendLayout(layout);
+ chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ chart.getStyler().setLegendBackgroundColor(Color.WHITE);
+ chart.getStyler().setYAxisMin(0.0);
+ chart.getStyler().setYAxisMax(10.0);
+ chart.addSeries("bars", Arrays.asList("c"), Arrays.asList(8)).setFillColor(FILL);
+ return chart;
+ }
+
+ private static HorizontalBarChart legendHorizontal(boolean gradient, Styler.LegendLayout layout) {
+
+ HorizontalBarChart chart = horizontalChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setLegendVisible(true);
+ chart.getStyler().setLegendLayout(layout);
+ chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ chart.getStyler().setLegendBackgroundColor(Color.WHITE);
+ chart.getStyler().setXAxisMin(0.0);
+ chart.getStyler().setXAxisMax(10.0);
+ chart.addSeries("bars", Arrays.asList(8), Arrays.asList("c")).setFillColor(FILL);
+ return chart;
+ }
+
+ private static CategoryChart categoryChart() {
+
+ CategoryChart chart = new CategoryChartBuilder().width(WIDTH).height(HEIGHT).build();
+ prepare(chart.getStyler());
+ return chart;
+ }
+
+ private static HorizontalBarChart horizontalChart() {
+
+ HorizontalBarChart chart = new HorizontalBarChartBuilder().width(WIDTH).height(HEIGHT).build();
+ prepare(chart.getStyler());
+ return chart;
+ }
+
+ private static void prepare(CategoryStyler styler) {
+
+ prepareAxes(styler);
+ styler.setAvailableSpaceFill(0.8);
+ }
+
+ private static void prepare(HorizontalBarStyler styler) {
+
+ prepareAxes(styler);
+ styler.setAvailableSpaceFill(0.8);
+ }
+
+ private static void prepareAxes(AxesChartStyler styler) {
+
+ styler.setAntiAlias(false);
+ styler.setTextAntiAlias(false);
+ styler.setPlotGridLinesVisible(false);
+ styler.setPlotTicksMarksVisible(false);
+ styler.setAxisTicksMarksVisible(false);
+ styler.setLegendVisible(false);
+ styler.setChartTitleVisible(false);
+ styler.setAxisTitlesVisible(false);
+ styler.setPlotBorderVisible(false);
+ styler.setPlotBackgroundColor(Color.WHITE);
+ styler.setChartBackgroundColor(Color.WHITE);
+ }
+
+ private static void assertSameYRange(Chart, ?> flat, Chart, ?> gradient) {
+
+ assertThat(yAxis(gradient).getMin()).isEqualTo(yAxis(flat).getMin());
+ assertThat(yAxis(gradient).getMax()).isEqualTo(yAxis(flat).getMax());
+ }
+
+ private static void assertSameXRange(Chart, ?> flat, Chart, ?> gradient) {
+
+ assertThat(xAxis(gradient).getMin()).isEqualTo(xAxis(flat).getMin());
+ assertThat(xAxis(gradient).getMax()).isEqualTo(xAxis(flat).getMax());
+ }
+
+ private static Axis_Y, ?> yAxis(Chart, ?> chart) {
+
+ return ((AxesChart, ?>) chart).getYAxis();
+ }
+
+ private static Axis_X, ?> xAxis(Chart, ?> chart) {
+
+ return ((AxesChart, ?>) chart).getXAxis();
+ }
+
+ private static Rectangle legendRegion(Chart, ?> chart) {
+
+ Legend_, ?> legend = ((Chart, ?>) chart).getLegend();
+ int x = (int) Math.floor(legend.xOffset);
+ int y = (int) Math.floor(legend.yOffset);
+ int w = (int) Math.ceil(legend.getBounds().getWidth());
+ int h = (int) Math.ceil(legend.getBounds().getHeight());
+ return new Rectangle(x, y, w, h);
+ }
+
+ private static void assertVerticalGradient(
+ BufferedImage image, Rectangle box, Color fill, boolean brighterAtTop) {
+
+ Color top = new Color(image.getRGB(sampleX(box), box.y + inset(box.height)));
+ Color bottom =
+ new Color(image.getRGB(sampleX(box), box.y + box.height - 1 - inset(box.height)));
+ Color valueEnd = brighterAtTop ? top : bottom;
+ Color baseEnd = brighterAtTop ? bottom : top;
+ assertEndpointOrder(valueEnd, baseEnd, fill);
+ }
+
+ private static void assertHorizontalGradient(
+ BufferedImage image, Rectangle box, Color fill, boolean brighterAtRight) {
+
+ int y = box.y + box.height / 3;
+ Color left = new Color(image.getRGB(box.x + inset(box.width), y));
+ Color right = new Color(image.getRGB(box.x + box.width - 1 - inset(box.width), y));
+ Color valueEnd = brighterAtRight ? right : left;
+ Color baseEnd = brighterAtRight ? left : right;
+ assertEndpointOrder(valueEnd, baseEnd, fill);
+ }
+
+ private static void assertFlatVertical(BufferedImage image, Rectangle box, Color fill) {
+
+ Color top = new Color(image.getRGB(sampleX(box), box.y + inset(box.height)));
+ Color bottom =
+ new Color(image.getRGB(sampleX(box), box.y + box.height - 1 - inset(box.height)));
+ assertFlat(top, bottom, fill);
+ }
+
+ private static void assertFlatHorizontal(BufferedImage image, Rectangle box, Color fill) {
+
+ int y = box.y + box.height / 2;
+ Color left = new Color(image.getRGB(box.x + inset(box.width), y));
+ Color right = new Color(image.getRGB(box.x + box.width - 1 - inset(box.width), y));
+ assertFlat(left, right, fill);
+ }
+
+ private static void assertEndpointOrder(Color valueEnd, Color baseEnd, Color fill) {
+
+ Color bright = fill.brighter();
+ assertThat(brightness(valueEnd))
+ .as(
+ "value end %s should be brighter than base %s (fill %s, bright %s)",
+ valueEnd, baseEnd, fill, bright)
+ .isGreaterThan(brightness(baseEnd) + BRIGHTNESS_DELTA);
+ assertThat(maxChannel(valueEnd, bright)).isLessThan(maxChannel(valueEnd, fill));
+ assertThat(maxChannel(baseEnd, fill)).isLessThan(maxChannel(baseEnd, bright));
+ }
+
+ private static void assertFlat(Color a, Color b, Color fill) {
+
+ assertThat(maxChannel(a, fill)).as("sample %s vs fill %s", a, fill).isLessThanOrEqualTo(4);
+ assertThat(maxChannel(b, fill)).as("sample %s vs fill %s", b, fill).isLessThanOrEqualTo(4);
+ assertThat(Math.abs(brightness(a) - brightness(b))).isLessThanOrEqualTo(6);
+ }
+
+ private static void assertAlphaGradient(BufferedImage image, Rectangle box, boolean vertical) {
+
+ Color base;
+ Color tip;
+ if (vertical) {
+ int x = sampleX(box);
+ tip = new Color(image.getRGB(x, box.y + inset(box.height)));
+ base = new Color(image.getRGB(x, box.y + box.height - 1 - inset(box.height)));
+ } else {
+ int y = box.y + box.height / 3;
+ base = new Color(image.getRGB(box.x + inset(box.width), y));
+ tip = new Color(image.getRGB(box.x + box.width - 1 - inset(box.width), y));
+ }
+ Color blendedFill = blend(ALPHA_FILL, Color.WHITE);
+ Color blendedBright = blend(ALPHA_FILL.brighter(), Color.WHITE);
+ Color opaqueFill = new Color(ALPHA_FILL.getRed(), ALPHA_FILL.getGreen(), ALPHA_FILL.getBlue());
+ Color opaqueBright =
+ new Color(
+ ALPHA_FILL.brighter().getRed(),
+ ALPHA_FILL.brighter().getGreen(),
+ ALPHA_FILL.brighter().getBlue());
+ assertThat(brightness(tip)).isGreaterThan(brightness(base) + BRIGHTNESS_DELTA);
+ assertThat(maxChannel(base, blendedFill)).isLessThan(maxChannel(base, opaqueFill));
+ assertThat(maxChannel(tip, blendedBright)).isLessThan(maxChannel(tip, opaqueBright));
+ }
+
+ private static void assertSolid(BufferedImage image, Color color) {
+
+ int flat = countNear(image, null, color, 4);
+ int drifted = 0;
+ int w = image.getWidth();
+ int h = image.getHeight();
+ int[] pixels = image.getRGB(0, 0, w, h, null, 0, w);
+ for (int rgb : pixels) {
+ if (maxChannel(rgb, color) > 12 && inGradient(rgb, color, 3)) {
+ drifted++;
+ }
+ }
+ assertThat(flat).as("flat pixels of %s", color).isGreaterThan(30);
+ assertThat(drifted).as("gradient pixels of %s", color).isZero();
+ }
+
+ private static int countNear(BufferedImage image, Rectangle region, Color color, int tolerance) {
+
+ int x0 = 0;
+ int y0 = 0;
+ int x1 = image.getWidth();
+ int y1 = image.getHeight();
+ if (region != null) {
+ x0 = Math.max(0, region.x);
+ y0 = Math.max(0, region.y);
+ x1 = Math.min(image.getWidth(), region.x + region.width);
+ y1 = Math.min(image.getHeight(), region.y + region.height);
+ }
+ int w = image.getWidth();
+ int[] pixels = image.getRGB(0, 0, w, image.getHeight(), null, 0, w);
+ int count = 0;
+ for (int y = y0; y < y1; y++) {
+ for (int x = x0; x < x1; x++) {
+ if (maxChannel(pixels[y * w + x], color) <= tolerance) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ private static Rectangle alphaBox(BufferedImage image) {
+
+ Color start = blend(ALPHA_FILL, Color.WHITE);
+ Color end = blend(ALPHA_FILL.brighter(), Color.WHITE);
+ List found = boxes(image, null, rgb -> betweenColors(rgb, start, end, 4));
+ assertThat(found).as("alpha gradient box").hasSize(1);
+ return found.get(0);
+ }
+
+ private static Rectangle onlyBox(BufferedImage image, Color fill, boolean gradient) {
+
+ return onlyBox(image, null, fill, gradient);
+ }
+
+ private static Rectangle onlyBox(
+ BufferedImage image, Rectangle region, Color fill, boolean gradient) {
+
+ List found = boxes(image, region, fill, gradient);
+ assertThat(found).as("boxes for %s gradient=%s", fill, gradient).hasSize(1);
+ return found.get(0);
+ }
+
+ private static Rectangle largestBox(
+ BufferedImage image, Rectangle region, Color fill, boolean gradient) {
+
+ List found = boxes(image, region, fill, gradient);
+ assertThat(found).as("boxes for %s", fill).isNotEmpty();
+ Rectangle largest = found.get(0);
+ for (Rectangle box : found) {
+ if (box.width * box.height > largest.width * largest.height) {
+ largest = box;
+ }
+ }
+ return largest;
+ }
+
+ private static List boxes(
+ BufferedImage image, Rectangle region, Color fill, boolean gradient) {
+
+ return boxes(image, region, rgb -> match(rgb, fill, gradient));
+ }
+
+ private static List boxes(
+ BufferedImage image, Rectangle region, IntPredicate matches) {
+
+ int w = image.getWidth();
+ int h = image.getHeight();
+ int x0 = 0;
+ int y0 = 0;
+ int x1 = w;
+ int y1 = h;
+ if (region != null) {
+ x0 = Math.max(0, region.x);
+ y0 = Math.max(0, region.y);
+ x1 = Math.min(w, region.x + region.width);
+ y1 = Math.min(h, region.y + region.height);
+ }
+ int[] pixels = image.getRGB(0, 0, w, h, null, 0, w);
+ boolean[] seen = new boolean[w * h];
+ ArrayDeque stack = new ArrayDeque<>();
+ List found = new ArrayList<>();
+ for (int y = y0; y < y1; y++) {
+ for (int x = x0; x < x1; x++) {
+ int start = y * w + x;
+ if (seen[start] || !matches.test(pixels[start])) {
+ continue;
+ }
+ int minX = x;
+ int maxX = x;
+ int minY = y;
+ int maxY = y;
+ seen[start] = true;
+ stack.push(start);
+ while (!stack.isEmpty()) {
+ int p = stack.pop();
+ int px = p % w;
+ int py = p / w;
+ minX = Math.min(minX, px);
+ maxX = Math.max(maxX, px);
+ minY = Math.min(minY, py);
+ maxY = Math.max(maxY, py);
+ consider(stack, seen, pixels, w, px - 1, py, x0, y0, x1, y1, matches);
+ consider(stack, seen, pixels, w, px + 1, py, x0, y0, x1, y1, matches);
+ consider(stack, seen, pixels, w, px, py - 1, x0, y0, x1, y1, matches);
+ consider(stack, seen, pixels, w, px, py + 1, x0, y0, x1, y1, matches);
+ }
+ int bw = maxX - minX + 1;
+ int bh = maxY - minY + 1;
+ if (bw >= 8 && bh >= 8) {
+ found.add(new Rectangle(minX, minY, bw, bh));
+ }
+ }
+ }
+ found.sort(Comparator.comparingInt((Rectangle r) -> r.x).thenComparingInt(r -> r.y));
+ return found;
+ }
+
+ private static void consider(
+ ArrayDeque stack,
+ boolean[] seen,
+ int[] pixels,
+ int w,
+ int x,
+ int y,
+ int x0,
+ int y0,
+ int x1,
+ int y1,
+ IntPredicate matches) {
+
+ if (x < x0 || y < y0 || x >= x1 || y >= y1) {
+ return;
+ }
+ int i = y * w + x;
+ if (seen[i] || !matches.test(pixels[i])) {
+ return;
+ }
+ seen[i] = true;
+ stack.push(i);
+ }
+
+ private static boolean match(int rgb, Color fill, boolean gradient) {
+
+ if (gradient) {
+ return inGradient(rgb, fill, 3);
+ }
+ return maxChannel(rgb, fill) <= 2;
+ }
+
+ private static boolean inGradient(int rgb, Color fill, int tolerance) {
+
+ Color bright = fill.brighter();
+ int r = (rgb >> 16) & 0xFF;
+ int g = (rgb >> 8) & 0xFF;
+ int b = rgb & 0xFF;
+ return between(r, fill.getRed(), bright.getRed(), tolerance)
+ && between(g, fill.getGreen(), bright.getGreen(), tolerance)
+ && between(b, fill.getBlue(), bright.getBlue(), tolerance);
+ }
+
+ private static boolean between(int value, int a, int b, int tolerance) {
+
+ return value >= Math.min(a, b) - tolerance && value <= Math.max(a, b) + tolerance;
+ }
+
+ private static boolean betweenColors(int rgb, Color start, Color end, int tolerance) {
+
+ int r = (rgb >> 16) & 0xFF;
+ int g = (rgb >> 8) & 0xFF;
+ int b = rgb & 0xFF;
+ return between(r, start.getRed(), end.getRed(), tolerance)
+ && between(g, start.getGreen(), end.getGreen(), tolerance)
+ && between(b, start.getBlue(), end.getBlue(), tolerance);
+ }
+
+ private static void assertClose(Rectangle flat, Rectangle gradient) {
+
+ assertThat(Math.abs(flat.x - gradient.x)).isLessThanOrEqualTo(1);
+ assertThat(Math.abs(flat.y - gradient.y)).isLessThanOrEqualTo(1);
+ assertThat(Math.abs(flat.width - gradient.width)).isLessThanOrEqualTo(1);
+ assertThat(Math.abs(flat.height - gradient.height)).isLessThanOrEqualTo(1);
+ }
+
+ private static int sampleX(Rectangle box) {
+
+ return box.x + Math.max(inset(box.width), box.width / 5);
+ }
+
+ private static int inset(int span) {
+
+ return Math.max(2, Math.min(4, (span - 1) / 4));
+ }
+
+ private static int brightness(Color color) {
+
+ return color.getRed() + color.getGreen() + color.getBlue();
+ }
+
+ private static int maxChannel(Color sample, Color expected) {
+
+ return Math.max(
+ Math.abs(sample.getRed() - expected.getRed()),
+ Math.max(
+ Math.abs(sample.getGreen() - expected.getGreen()),
+ Math.abs(sample.getBlue() - expected.getBlue())));
+ }
+
+ private static int maxChannel(int rgb, Color expected) {
+
+ int r = (rgb >> 16) & 0xFF;
+ int g = (rgb >> 8) & 0xFF;
+ int b = rgb & 0xFF;
+ return Math.max(
+ Math.abs(r - expected.getRed()),
+ Math.max(Math.abs(g - expected.getGreen()), Math.abs(b - expected.getBlue())));
+ }
+
+ private static Color blend(Color foreground, Color background) {
+
+ float alpha = foreground.getAlpha() / 255f;
+ int r = Math.round(foreground.getRed() * alpha + background.getRed() * (1f - alpha));
+ int g = Math.round(foreground.getGreen() * alpha + background.getGreen() * (1f - alpha));
+ int b = Math.round(foreground.getBlue() * alpha + background.getBlue() * (1f - alpha));
+ return new Color(r, g, b);
+ }
+}
From 0b00041e89a38f1c4361c7dc18908eb6c200a95e Mon Sep 17 00:00:00 2001
From: Matt Van Horn
Date: Fri, 30 Oct 2026 00:32:18 -0700
Subject: [PATCH 2/2] Share the bar gradient helper and address review feedback
BarGradient now holds the one fill routine used by both bar plots and
both legends. The value-end color blends the fill 40% toward white
(alpha kept) instead of Color.brighter(), so pure red, green, blue and
black fills get a visible gradient.
- Gradient endpoints are clamped to the plot, so a bar clipped at an
axis limit still spans the whole gradient.
- Inside labels with automatic font color compute contrast against the
gradient at the label position.
- setTheme() no longer resets the opt-in; no theme carries it.
- Vector graphics backends get the flat fill, since their writers drop
GradientPaint and fill from the last setColor.
- Legend swatches follow the bar direction on an all-negative axis.
---
.../internal/chartpart/BarGradient.java | 105 ++++++++++
.../chartpart/Legend_HorizontalBar.java | 37 ++--
.../internal/chartpart/Legend_Marker.java | 40 ++--
.../chartpart/PlotContent_Category.java | 79 ++------
.../chartpart/PlotContent_HorizontalBar.java | 74 ++-----
.../knowm/xchart/style/CategoryStyler.java | 10 +-
.../xchart/style/HorizontalBarStyler.java | 10 +-
.../chartpart/BarFillGradientTest.java | 180 ++++++++++++++++--
8 files changed, 362 insertions(+), 173 deletions(-)
create mode 100644 xchart/src/main/java/org/knowm/xchart/internal/chartpart/BarGradient.java
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/BarGradient.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/BarGradient.java
new file mode 100644
index 000000000..25eed405e
--- /dev/null
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/BarGradient.java
@@ -0,0 +1,105 @@
+package org.knowm.xchart.internal.chartpart;
+
+import java.awt.Color;
+import java.awt.GradientPaint;
+import java.awt.Graphics2D;
+import java.awt.Paint;
+import java.awt.Shape;
+
+/**
+ * Shared two-color bar gradient used by the category and horizontal bar plots and their legends.
+ * The gradient runs from the series fill at the bar base to {@link #endColor(Color)} at the value
+ * end.
+ */
+final class BarGradient {
+
+ /** Share of white blended into the fill to get the value-end color. */
+ static final float END_COLOR_WHITE_BLEND = 0.4f;
+
+ private static final String VECTOR_GRAPHICS_PACKAGE = "de.erichseifert.vectorgraphics2d";
+
+ private BarGradient() {}
+
+ /**
+ * The value-end color: {@code fill} blended toward white, alpha kept. Unlike {@link
+ * Color#brighter()}, this also lightens saturated and black fills such as {@link Color#RED} or
+ * {@link Color#BLACK}. Only a white fill has no lighter end.
+ */
+ static Color endColor(Color fill) {
+
+ return lerp(fill, Color.WHITE, END_COLOR_WHITE_BLEND);
+ }
+
+ /**
+ * Color of the gradient at {@code fraction} of the way from the base (0) to the value end (1),
+ * clamped to that range.
+ */
+ static Color colorAt(Color fill, double fraction) {
+
+ double t = Math.max(0.0, Math.min(1.0, fraction));
+ return lerp(fill, endColor(fill), t);
+ }
+
+ /** True when the bar grows toward more negative transformed values. */
+ static boolean extendsNegative(int chartForm, double transformed) {
+
+ if (chartForm > 0) {
+ return false;
+ }
+ if (chartForm < 0) {
+ return true;
+ }
+ return transformed < 0.0;
+ }
+
+ /**
+ * Fills {@code shape} with {@code fill}, or with a non-cyclic gradient from {@code fill} at the
+ * base to {@link #endColor(Color)} at the value end. Coincident endpoints keep the flat fill, as
+ * do vector backends, whose writers fill from the last {@code setColor} and drop a {@link
+ * GradientPaint}. The paint in effect before a gradient fill is restored.
+ */
+ static void fill(
+ Graphics2D g,
+ Shape shape,
+ Color fill,
+ boolean gradient,
+ double baseX,
+ double baseY,
+ double valueX,
+ double valueY) {
+
+ if (gradient && fill != null && !isVectorGraphics(g)) {
+ float x1 = (float) baseX;
+ float y1 = (float) baseY;
+ float x2 = (float) valueX;
+ float y2 = (float) valueY;
+ if (x1 != x2 || y1 != y2) {
+ Paint previousPaint = g.getPaint();
+ g.setPaint(new GradientPaint(x1, y1, fill, x2, y2, endColor(fill), false));
+ g.fill(shape);
+ g.setPaint(previousPaint);
+ return;
+ }
+ }
+ g.setColor(fill);
+ g.fill(shape);
+ }
+
+ static double clamp(double value, double min, double max) {
+
+ return Math.max(min, Math.min(max, value));
+ }
+
+ private static boolean isVectorGraphics(Graphics2D g) {
+
+ return g.getClass().getName().startsWith(VECTOR_GRAPHICS_PACKAGE);
+ }
+
+ private static Color lerp(Color from, Color to, double t) {
+
+ int r = (int) Math.round(from.getRed() + (to.getRed() - from.getRed()) * t);
+ int g = (int) Math.round(from.getGreen() + (to.getGreen() - from.getGreen()) * t);
+ int b = (int) Math.round(from.getBlue() + (to.getBlue() - from.getBlue()) * t);
+ return new Color(r, g, b, from.getAlpha());
+ }
+}
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 a89fd50b9..61fdb1aa4 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
@@ -38,6 +38,8 @@ public void doPaint(Graphics2D g) {
boolean barFillGradient =
chart.getStyler() instanceof HorizontalBarStyler
&& ((HorizontalBarStyler) chart.getStyler()).isBarFillGradientEnabled();
+ // Match the bars: lighter at the right, or at the left when every bar grows leftward.
+ boolean allNegative = barFillGradient && allNegativeXAxis();
// 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.
@@ -66,21 +68,19 @@ public void doPaint(Graphics2D g) {
starty = cursor.y;
}
- // paint inner box. Enabled charts use a left-to-right positive-bar gradient.
+ // paint inner box, with the bar gradient when enabled
Shape rectSmall = new Rectangle2D.Double(startx, starty, BOX_SIZE, BOX_SIZE);
- Color fill = series.getFillColor();
- if (barFillGradient && fill != null) {
- Paint previousPaint = g.getPaint();
- float baseX = (float) startx;
- float valueX = (float) (startx + BOX_SIZE);
- float y = (float) starty;
- g.setPaint(new GradientPaint(baseX, y, fill, valueX, y, fill.brighter(), false));
- g.fill(rectSmall);
- g.setPaint(previousPaint);
- } else {
- g.setColor(fill);
- g.fill(rectSmall);
- }
+ double left = startx;
+ double right = startx + BOX_SIZE;
+ BarGradient.fill(
+ g,
+ rectSmall,
+ series.getFillColor(),
+ barFillGradient,
+ allNegative ? right : left,
+ starty,
+ allNegative ? left : right,
+ starty);
// paint series text
double x = startx + BOX_SIZE + chart.getStyler().getLegendPadding();
@@ -100,4 +100,13 @@ public double getSeriesLegendRenderGraphicHeight(S series) {
return BOX_SIZE;
}
+
+ private boolean allNegativeXAxis() {
+
+ if (!(chart instanceof AxesChart)) {
+ return false;
+ }
+ Axis_X, ?> xAxis = ((AxesChart, ?>) chart).getXAxis();
+ return xAxis.getMin() < 0.0 && xAxis.getMax() < 0.0;
+ }
}
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 db4a58d56..47eea40c4 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
@@ -183,25 +183,35 @@ public void doPaint(Graphics2D g) {
}
/**
- * Category bars with the gradient enabled use a bottom-to-top positive-bar gradient inside the
- * swatch. Every other legend entry, including non-bar category styles and charts that share this
- * renderer, keeps the flat fill.
+ * Category bars with the gradient enabled use the bar gradient inside the swatch, lighter at the
+ * top, or at the bottom when every bar on the plot grows downward (an all-negative y-axis). Every
+ * other legend entry, including non-bar category styles and charts that share this renderer,
+ * keeps the flat fill.
*/
private void fillLegendBox(Graphics2D g, S series, Shape box, double x, double y) {
- Color fill = series.getFillColor();
- if (isCategoryBarGradient(series) && fill != null) {
- Paint previousPaint = g.getPaint();
- float baseX = (float) x;
- float baseY = (float) (y + BOX_SIZE);
- float valueY = (float) y;
- g.setPaint(new GradientPaint(baseX, baseY, fill, baseX, valueY, fill.brighter(), false));
- g.fill(box);
- g.setPaint(previousPaint);
- } else {
- g.setColor(fill);
- g.fill(box);
+ boolean gradient = isCategoryBarGradient(series);
+ double top = y;
+ double bottom = y + BOX_SIZE;
+ boolean negative = gradient && allNegativeYAxis();
+ BarGradient.fill(
+ g,
+ box,
+ series.getFillColor(),
+ gradient,
+ x,
+ negative ? top : bottom,
+ x,
+ negative ? bottom : top);
+ }
+
+ private boolean allNegativeYAxis() {
+
+ if (!(chart instanceof AxesChart)) {
+ return false;
}
+ Axis_Y, ?> yAxis = ((AxesChart, ?>) chart).getYAxis();
+ return yAxis.getMin() < 0.0 && yAxis.getMax() < 0.0;
}
private boolean isCategoryBarGradient(S series) {
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java
index ffaaf804f..e1acbfff3 100644
--- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_Category.java
@@ -287,21 +287,20 @@ public void doPaint(Graphics2D g) {
barPath.lineTo(xOffset, zeroOffset);
barPath.closePath();
- // Gradient runs from the segment base (fill) to the value end (brighter). Positive-only
+ // Gradient runs from the segment base (fill) to the value end (lighter). Positive-only
// axes, including logarithmic axes, always use that direction. Negative bars flip it so
- // the brighter end stays away from zero. Each stacked segment restarts at its own base.
- boolean negativeBar = barExtendsNegative(chartForm, y);
- double baseY = negativeBar ? yOffset : zeroOffset;
- double valueY = negativeBar ? zeroOffset : yOffset;
- fillBar(
- g,
- barPath,
- series.getFillColor(),
- stylerCategory.isBarFillGradientEnabled(),
- xOffset,
- baseY,
- xOffset,
- valueY);
+ // the lighter end stays away from zero. Each stacked segment restarts at its own base.
+ // Endpoints are clamped to the plot so a bar clipped at an axis limit still spans the
+ // whole gradient.
+ boolean gradient = stylerCategory.isBarFillGradientEnabled();
+ boolean negativeBar = BarGradient.extendsNegative(chartForm, y);
+ double plotTop = getBounds().getMinY();
+ double plotBottom = getBounds().getMaxY();
+ double baseY = BarGradient.clamp(negativeBar ? yOffset : zeroOffset, plotTop, plotBottom);
+ double valueY =
+ BarGradient.clamp(negativeBar ? zeroOffset : yOffset, plotTop, plotBottom);
+ BarGradient.fill(
+ g, barPath, series.getFillColor(), gradient, xOffset, baseY, xOffset, valueY);
// TODO maybe we want outlines of the bars?
// Legend markers now also draw the outline. It has been disabled for
@@ -324,6 +323,12 @@ public void doPaint(Graphics2D g) {
// }
if (stylerCategory.isLabelsVisible()) {
+ // An inside label sits labelsPosition of the way from the base to the value end, so
+ // its automatic contrast color is computed against the gradient at that point.
+ Color labelBackground =
+ gradient
+ ? BarGradient.colorAt(series.getFillColor(), stylerCategory.getLabelsPosition())
+ : series.getFillColor();
drawLabels(
g,
next,
@@ -333,7 +338,7 @@ public void doPaint(Graphics2D g) {
barWidth,
false,
false,
- series.getFillColor());
+ labelBackground);
}
if (stylerCategory.isLabelsVisible()
&& stylerCategory.isShowStackSum()
@@ -796,50 +801,6 @@ private void drawStepBar(
drawStepBarLine(g, series, drawPath);
}
- /** True when the bar grows toward more negative transformed values. */
- private static boolean barExtendsNegative(int chartForm, double transformed) {
-
- if (chartForm > 0) {
- return false;
- }
- if (chartForm < 0) {
- return true;
- }
- return transformed < 0.0;
- }
-
- /**
- * Fills {@code bar} with {@code fill}, or with a non-cyclic gradient from {@code fill} at the
- * base to {@code fill.brighter()} at the value end. Coincident endpoints keep the flat fill. The
- * paint in effect before a gradient fill is restored.
- */
- private static void fillBar(
- Graphics2D g,
- Shape bar,
- Color fill,
- boolean gradient,
- double baseX,
- double baseY,
- double valueX,
- double valueY) {
-
- if (gradient && fill != null) {
- float x1 = (float) baseX;
- float y1 = (float) baseY;
- float x2 = (float) valueX;
- float y2 = (float) valueY;
- if (x1 != x2 || y1 != y2) {
- Paint previousPaint = g.getPaint();
- g.setPaint(new GradientPaint(x1, y1, fill, x2, y2, fill.brighter(), false));
- g.fill(bar);
- g.setPaint(previousPaint);
- return;
- }
- }
- g.setColor(fill);
- g.fill(bar);
- }
-
private void drawLabels(
Graphics2D g,
double next,
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java
index fa5107f7d..da9898b08 100644
--- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/PlotContent_HorizontalBar.java
@@ -167,22 +167,24 @@ public void doPaint(Graphics2D g) {
// Same base-to-value gradient as category bars, along X. xOffset is the right edge and
// zeroOffset the left, including for negative bars. Positive-only axes (including a
- // logarithmic axis) keep the brighter end at the value; negative bars flip it.
- boolean negativeBar = barExtendsNegative(chartForm, x);
- double baseX = negativeBar ? xOffset : zeroOffset;
- double valueX = negativeBar ? zeroOffset : xOffset;
- fillBar(
- g,
- barPath,
- series.getFillColor(),
- styler.isBarFillGradientEnabled(),
- baseX,
- yOffset,
- valueX,
- yOffset);
+ // logarithmic axis) keep the lighter end at the value; negative bars flip it. Endpoints
+ // are clamped to the plot so a bar clipped at an axis limit still spans the gradient.
+ boolean gradient = styler.isBarFillGradientEnabled();
+ boolean negativeBar = BarGradient.extendsNegative(chartForm, x);
+ double plotLeft = getBounds().getMinX();
+ double plotRight = getBounds().getMaxX();
+ double baseX = BarGradient.clamp(negativeBar ? xOffset : zeroOffset, plotLeft, plotRight);
+ double valueX = BarGradient.clamp(negativeBar ? zeroOffset : xOffset, plotLeft, plotRight);
+ BarGradient.fill(
+ g, barPath, series.getFillColor(), gradient, baseX, yOffset, valueX, yOffset);
if (styler.isLabelsVisible() && next != null) {
- drawLabels(g, next, xOffset, yOffset, zeroOffset, barHeight, series.getFillColor());
+ // Inside labels get their automatic contrast color from the gradient at their position.
+ Color labelBackground =
+ gradient
+ ? BarGradient.colorAt(series.getFillColor(), styler.getLabelsPosition())
+ : series.getFillColor();
+ drawLabels(g, next, xOffset, yOffset, zeroOffset, barHeight, labelBackground);
}
// add data labels
@@ -214,50 +216,6 @@ public void doPaint(Graphics2D g) {
}
}
- /** True when the bar grows toward more negative transformed values. */
- private static boolean barExtendsNegative(int chartForm, double transformed) {
-
- if (chartForm > 0) {
- return false;
- }
- if (chartForm < 0) {
- return true;
- }
- return transformed < 0.0;
- }
-
- /**
- * Fills {@code bar} with {@code fill}, or with a non-cyclic gradient from {@code fill} at the
- * base to {@code fill.brighter()} at the value end. Coincident endpoints keep the flat fill. The
- * paint in effect before a gradient fill is restored.
- */
- private static void fillBar(
- Graphics2D g,
- Shape bar,
- Color fill,
- boolean gradient,
- double baseX,
- double baseY,
- double valueX,
- double valueY) {
-
- if (gradient && fill != null) {
- float x1 = (float) baseX;
- float y1 = (float) baseY;
- float x2 = (float) valueX;
- float y2 = (float) valueY;
- if (x1 != x2 || y1 != y2) {
- Paint previousPaint = g.getPaint();
- g.setPaint(new GradientPaint(x1, y1, fill, x2, y2, fill.brighter(), false));
- g.fill(bar);
- g.setPaint(previousPaint);
- return;
- }
- }
- g.setColor(fill);
- g.fill(bar);
- }
-
private void drawLabels(
Graphics2D g,
Number next,
diff --git a/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java b/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java
index fad36c6e3..37ecaaa95 100644
--- a/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java
+++ b/xchart/src/main/java/org/knowm/xchart/style/CategoryStyler.java
@@ -51,7 +51,6 @@ protected void setAllStyles() {
isLabelsFontColorAutomaticEnabled = theme.isLabelsFontColorAutomaticEnabled();
labelsFontColorAutomaticLight = theme.getLabelsFontColorAutomaticLight();
labelsFontColorAutomaticDark = theme.getLabelsFontColorAutomaticDark();
- isBarFillGradientEnabled = false;
}
public CategorySeriesRenderStyle getDefaultSeriesRenderStyle() {
@@ -269,9 +268,12 @@ public boolean isBarFillGradientEnabled() {
/**
* Enables a two-color gradient fill for category bars. The series fill color is painted at the
- * bar base and {@code fill.brighter()} at the value end. Negative bars reverse that direction so
- * the brighter end stays at the value, away from zero. Disabled by default, which keeps the flat
- * series fill. A bar whose base and value endpoints coincide also stays flat.
+ * bar base and a lighter tint of it (the fill blended 40% toward white, alpha kept) at the value
+ * end. Negative bars reverse that direction so the lighter end stays at the value, away from
+ * zero. Disabled by default, which keeps the flat series fill. A bar whose base and value
+ * endpoints coincide also stays flat. The setting is not part of a theme, so {@code setTheme}
+ * leaves it unchanged. Vector exports ({@code VectorGraphicsEncoder}) draw flat fills because
+ * the vector backend does not render gradient paints.
*
* @param barFillGradientEnabled {@code true} to paint bars with the gradient
* @return this styler
diff --git a/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java b/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java
index 9b3ed2934..c350ad7e3 100644
--- a/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java
+++ b/xchart/src/main/java/org/knowm/xchart/style/HorizontalBarStyler.java
@@ -41,7 +41,6 @@ protected void setAllStyles() {
isLabelsFontColorAutomaticEnabled = theme.isLabelsFontColorAutomaticEnabled();
labelsFontColorAutomaticLight = theme.getLabelsFontColorAutomaticLight();
labelsFontColorAutomaticDark = theme.getLabelsFontColorAutomaticDark();
- isBarFillGradientEnabled = false;
}
public double getAvailableSpaceFill() {
@@ -189,9 +188,12 @@ public boolean isBarFillGradientEnabled() {
/**
* Enables a two-color gradient fill for horizontal bars. The series fill color is painted at the
- * bar base and {@code fill.brighter()} at the value end. Negative bars reverse that direction so
- * the brighter end stays at the value, away from zero. Disabled by default, which keeps the flat
- * series fill. A bar whose base and value endpoints coincide also stays flat.
+ * bar base and a lighter tint of it (the fill blended 40% toward white, alpha kept) at the value
+ * end. Negative bars reverse that direction so the lighter end stays at the value, away from
+ * zero. Disabled by default, which keeps the flat series fill. A bar whose base and value
+ * endpoints coincide also stays flat. The setting is not part of a theme, so {@code setTheme}
+ * leaves it unchanged. Vector exports ({@code VectorGraphicsEncoder}) draw flat fills because
+ * the vector backend does not render gradient paints.
*
* @param barFillGradientEnabled {@code true} to paint bars with the gradient
* @return this styler
diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java
index 30f181a3e..f5aadbba6 100644
--- a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java
+++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/BarFillGradientTest.java
@@ -54,7 +54,7 @@ public class BarFillGradientTest {
private static final int BRIGHTNESS_DELTA = 20;
@Test
- public void stylersDefaultToFalseAndResetWithTheTheme() {
+ public void stylersDefaultToFalseAndKeepTheOptInAcrossThemes() {
CategoryChart category = new CategoryChartBuilder().width(200).height(150).build();
CategoryStyler categoryStyler = category.getStyler();
@@ -63,7 +63,7 @@ public void stylersDefaultToFalseAndResetWithTheTheme() {
assertThat(categoryStyler.isBarFillGradientEnabled()).isTrue();
categoryStyler.setLabelsVisible(true);
categoryStyler.setTheme(new GGPlot2Theme());
- assertThat(categoryStyler.isBarFillGradientEnabled()).isFalse();
+ assertThat(categoryStyler.isBarFillGradientEnabled()).isTrue();
assertThat(categoryStyler.isLabelsVisible()).isFalse();
assertThat(categoryStyler.setBarFillGradientEnabled(false).isBarFillGradientEnabled())
.isFalse();
@@ -75,10 +75,122 @@ public void stylersDefaultToFalseAndResetWithTheTheme() {
assertThat(horizontalStyler.isBarFillGradientEnabled()).isTrue();
horizontalStyler.setLabelsVisible(true);
horizontalStyler.setTheme(new XChartTheme());
- assertThat(horizontalStyler.isBarFillGradientEnabled()).isFalse();
+ assertThat(horizontalStyler.isBarFillGradientEnabled()).isTrue();
assertThat(horizontalStyler.isLabelsVisible()).isFalse();
}
+ @Test
+ public void endColorLightensSaturatedAndBlackFills() {
+
+ for (Color fill :
+ Arrays.asList(Color.RED, Color.GREEN, Color.BLUE, Color.BLACK, new Color(255, 0, 0, 90))) {
+ Color end = BarGradient.endColor(fill);
+ assertThat(brightness(end)).as("end of %s", fill).isGreaterThan(brightness(fill) + 150);
+ assertThat(end.getAlpha()).isEqualTo(fill.getAlpha());
+ }
+ assertThat(BarGradient.endColor(Color.RED)).isEqualTo(new Color(255, 102, 102));
+ assertThat(BarGradient.colorAt(FILL, 0.0)).isEqualTo(FILL);
+ assertThat(BarGradient.colorAt(FILL, 1.0)).isEqualTo(BarGradient.endColor(FILL));
+ assertThat(BarGradient.colorAt(FILL, 2.0)).isEqualTo(BarGradient.endColor(FILL));
+ Color mid = BarGradient.colorAt(FILL, 0.5);
+ assertThat(brightness(mid))
+ .isStrictlyBetween(brightness(FILL), brightness(BarGradient.endColor(FILL)));
+ }
+
+ @Test
+ public void pureRedBarsGetAVisibleGradient() {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(true);
+ chart.getStyler().setYAxisMin(0.0);
+ chart.getStyler().setYAxisMax(10.0);
+ chart.addSeries("a", Arrays.asList("x"), Arrays.asList(8)).setFillColor(Color.RED);
+ BufferedImage image = BitmapEncoder.getBufferedImage(chart);
+ assertVerticalGradient(image, onlyBox(image, Color.RED, true), Color.RED, true);
+
+ HorizontalBarChart horizontal = horizontalChart();
+ horizontal.getStyler().setBarFillGradientEnabled(true);
+ horizontal.getStyler().setXAxisMin(0.0);
+ horizontal.getStyler().setXAxisMax(10.0);
+ horizontal.addSeries("a", Arrays.asList(8), Arrays.asList("x")).setFillColor(Color.RED);
+ BufferedImage hImage = BitmapEncoder.getBufferedImage(horizontal);
+ assertHorizontalGradient(hImage, onlyBox(hImage, Color.RED, true), Color.RED, true);
+ }
+
+ @Test
+ public void automaticLabelColorUsesTheGradientUnderTheLabel() {
+
+ // Flat fill is just dark enough for a light label; the lighter value end needs a dark one.
+ Color fill = new Color(110, 110, 110);
+ for (boolean gradient : new boolean[] {false, true}) {
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(gradient);
+ chart.getStyler().setYAxisMin(0.0);
+ chart.getStyler().setYAxisMax(10.0);
+ chart.getStyler().setLabelsVisible(true);
+ chart.getStyler().setLabelsPosition(1.0);
+ chart.getStyler().setLabelsFontColorAutomaticEnabled(true);
+ chart.getStyler().setLabelsFontColorAutomaticLight(Color.WHITE);
+ chart.getStyler().setLabelsFontColorAutomaticDark(LABEL);
+ chart.addSeries("a", Arrays.asList("x"), Arrays.asList(8)).setFillColor(fill);
+ BufferedImage image = BitmapEncoder.getBufferedImage(chart);
+ Color expected =
+ chart.getStyler().getLabelsFontColor(gradient ? BarGradient.endColor(fill) : fill);
+ Color other = expected.equals(LABEL) ? Color.WHITE : LABEL;
+ assertThat(expected).isNotEqualTo(other);
+ if (gradient) {
+ assertThat(expected).isEqualTo(LABEL);
+ assertThat(countNear(image, null, LABEL, 2)).isGreaterThan(0);
+ } else {
+ assertThat(expected).isEqualTo(Color.WHITE);
+ assertThat(countNear(image, null, LABEL, 2)).isZero();
+ }
+ }
+ }
+
+ @Test
+ public void legendSwatchesFollowAllNegativeBars() {
+
+ CategoryChart chart = categoryChart();
+ chart.getStyler().setBarFillGradientEnabled(true);
+ chart.getStyler().setLegendVisible(true);
+ chart.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ chart.getStyler().setLegendBackgroundColor(Color.WHITE);
+ chart.getStyler().setYAxisMin(-30.0);
+ chart.getStyler().setYAxisMax(-1.0);
+ chart.addSeries("bars", Arrays.asList("c"), Arrays.asList(-20)).setFillColor(FILL);
+ BufferedImage image = BitmapEncoder.getBufferedImage(chart);
+ Rectangle legend = legendRegion(chart);
+ assertVerticalGradient(image, onlyBox(image, legend, FILL, true), FILL, false);
+ List bars = boxes(image, null, FILL, true);
+ bars.removeIf(legend::intersects);
+ assertThat(bars).hasSize(1);
+ assertVerticalGradient(image, bars.get(0), FILL, false);
+
+ HorizontalBarChart horizontal = horizontalChart();
+ horizontal.getStyler().setBarFillGradientEnabled(true);
+ horizontal.getStyler().setLegendVisible(true);
+ horizontal.getStyler().setLegendPosition(Styler.LegendPosition.OutsideE);
+ horizontal.getStyler().setLegendBackgroundColor(Color.WHITE);
+ horizontal.getStyler().setXAxisMin(-30.0);
+ horizontal.getStyler().setXAxisMax(-1.0);
+ horizontal.addSeries("bars", Arrays.asList(-20), Arrays.asList("c")).setFillColor(FILL);
+ BufferedImage hImage = BitmapEncoder.getBufferedImage(horizontal);
+ Rectangle hLegend = legendRegion(horizontal);
+ assertHorizontalGradient(hImage, onlyBox(hImage, hLegend, FILL, true), FILL, false);
+ }
+
+ @Test
+ public void vectorGraphicsGetTheFlatFill() {
+
+ de.erichseifert.vectorgraphics2d.VectorGraphics2D vg =
+ new de.erichseifert.vectorgraphics2d.VectorGraphics2D();
+ vg.setColor(Color.GRAY);
+ BarGradient.fill(vg, new Rectangle(0, 0, 10, 10), FILL, true, 0, 10, 0, 0);
+ assertThat(vg.getPaint()).isEqualTo(FILL);
+ assertThat(vg.getColor()).isEqualTo(FILL);
+ }
+
@Test
public void barsAreFlatByDefaultAndAfterDisabling() {
@@ -235,6 +347,9 @@ public void zeroLengthNullClippingAndLogAxisRender() {
assertVerticalGradient(clippedImage, clippedBoxes.get(0), FILL, true);
assertVerticalGradient(clippedImage, clippedBoxes.get(1), FILL, true);
assertThat(clippedBoxes.get(1).height).isGreaterThan(clippedBoxes.get(0).height);
+ // The bar above yAxisMax is clipped, but its gradient is clamped to the plot, so the visible
+ // top still reaches the end color.
+ assertEndsNear(clippedImage, clippedBoxes.get(1), FILL, true);
CategoryChart logOff = logCategory(false);
CategoryChart logOn = logCategory(true);
@@ -279,6 +394,7 @@ public void zeroLengthNullClippingAndLogAxisRender() {
assertHorizontalGradient(clippedHImage, clippedHBoxes.get(0), FILL, true);
assertHorizontalGradient(clippedHImage, clippedHBoxes.get(1), FILL, true);
assertThat(clippedHBoxes.get(1).width).isGreaterThan(clippedHBoxes.get(0).width);
+ assertEndsNear(clippedHImage, clippedHBoxes.get(1), FILL, false);
HorizontalBarChart logHOff = logHorizontal(false);
HorizontalBarChart logHOn = logHorizontal(true);
@@ -655,9 +771,23 @@ private static void assertFlatHorizontal(BufferedImage image, Rectangle box, Col
assertFlat(left, right, fill);
}
+ private static void assertEndsNear(
+ BufferedImage image, Rectangle box, Color fill, boolean vertical) {
+
+ Color valueEnd;
+ if (vertical) {
+ valueEnd = new Color(image.getRGB(sampleX(box), box.y + 1));
+ } else {
+ valueEnd = new Color(image.getRGB(box.x + box.width - 2, box.y + box.height / 3));
+ }
+ assertThat(maxChannel(valueEnd, BarGradient.endColor(fill)))
+ .as("visible value end %s vs end color %s", valueEnd, BarGradient.endColor(fill))
+ .isLessThanOrEqualTo(6);
+ }
+
private static void assertEndpointOrder(Color valueEnd, Color baseEnd, Color fill) {
- Color bright = fill.brighter();
+ Color bright = BarGradient.endColor(fill);
assertThat(brightness(valueEnd))
.as(
"value end %s should be brighter than base %s (fill %s, bright %s)",
@@ -688,13 +818,13 @@ private static void assertAlphaGradient(BufferedImage image, Rectangle box, bool
tip = new Color(image.getRGB(box.x + box.width - 1 - inset(box.width), y));
}
Color blendedFill = blend(ALPHA_FILL, Color.WHITE);
- Color blendedBright = blend(ALPHA_FILL.brighter(), Color.WHITE);
+ Color blendedBright = blend(BarGradient.endColor(ALPHA_FILL), Color.WHITE);
Color opaqueFill = new Color(ALPHA_FILL.getRed(), ALPHA_FILL.getGreen(), ALPHA_FILL.getBlue());
Color opaqueBright =
new Color(
- ALPHA_FILL.brighter().getRed(),
- ALPHA_FILL.brighter().getGreen(),
- ALPHA_FILL.brighter().getBlue());
+ BarGradient.endColor(ALPHA_FILL).getRed(),
+ BarGradient.endColor(ALPHA_FILL).getGreen(),
+ BarGradient.endColor(ALPHA_FILL).getBlue());
assertThat(brightness(tip)).isGreaterThan(brightness(base) + BRIGHTNESS_DELTA);
assertThat(maxChannel(base, blendedFill)).isLessThan(maxChannel(base, opaqueFill));
assertThat(maxChannel(tip, blendedBright)).isLessThan(maxChannel(tip, opaqueBright));
@@ -702,18 +832,20 @@ private static void assertAlphaGradient(BufferedImage image, Rectangle box, bool
private static void assertSolid(BufferedImage image, Color color) {
+ // The gradient end is the fill blended toward white, so a translucent flat fill over a white
+ // plot also lands in that range. A gradient shows up as many distinct shades instead.
int flat = countNear(image, null, color, 4);
- int drifted = 0;
+ java.util.Set shades = new java.util.HashSet<>();
int w = image.getWidth();
int h = image.getHeight();
int[] pixels = image.getRGB(0, 0, w, h, null, 0, w);
for (int rgb : pixels) {
if (maxChannel(rgb, color) > 12 && inGradient(rgb, color, 3)) {
- drifted++;
+ shades.add(rgb & 0xFFFFFF);
}
}
assertThat(flat).as("flat pixels of %s", color).isGreaterThan(30);
- assertThat(drifted).as("gradient pixels of %s", color).isZero();
+ assertThat(shades.size()).as("gradient shades of %s: %s", color, shades).isLessThanOrEqualTo(4);
}
private static int countNear(BufferedImage image, Rectangle region, Color color, int tolerance) {
@@ -744,7 +876,7 @@ private static int countNear(BufferedImage image, Rectangle region, Color color,
private static Rectangle alphaBox(BufferedImage image) {
Color start = blend(ALPHA_FILL, Color.WHITE);
- Color end = blend(ALPHA_FILL.brighter(), Color.WHITE);
+ Color end = blend(BarGradient.endColor(ALPHA_FILL), Color.WHITE);
List found = boxes(image, null, rgb -> betweenColors(rgb, start, end, 4));
assertThat(found).as("alpha gradient box").hasSize(1);
return found.get(0);
@@ -870,15 +1002,25 @@ private static boolean match(int rgb, Color fill, boolean gradient) {
return maxChannel(rgb, fill) <= 2;
}
+ /** True when {@code rgb} lies within {@code tolerance} of the fill-to-end color segment. */
private static boolean inGradient(int rgb, Color fill, int tolerance) {
- Color bright = fill.brighter();
- int r = (rgb >> 16) & 0xFF;
- int g = (rgb >> 8) & 0xFF;
- int b = rgb & 0xFF;
- return between(r, fill.getRed(), bright.getRed(), tolerance)
- && between(g, fill.getGreen(), bright.getGreen(), tolerance)
- && between(b, fill.getBlue(), bright.getBlue(), tolerance);
+ Color end = BarGradient.endColor(fill);
+ double[] p = {(rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF};
+ double[] a = {fill.getRed(), fill.getGreen(), fill.getBlue()};
+ double[] d = {end.getRed() - a[0], end.getGreen() - a[1], end.getBlue() - a[2]};
+ double len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
+ double t = 0.0;
+ if (len2 > 0) {
+ t = ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1] + (p[2] - a[2]) * d[2]) / len2;
+ t = Math.max(0.0, Math.min(1.0, t));
+ }
+ double dist2 = 0.0;
+ for (int i = 0; i < 3; i++) {
+ double diff = p[i] - (a[i] + d[i] * t);
+ dist2 += diff * diff;
+ }
+ return dist2 <= (double) tolerance * tolerance * 3;
}
private static boolean between(int value, int a, int b, int tolerance) {