diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24174e3a3..2ffbdbb00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -242,6 +242,17 @@ Entries land here as they merge.
total and ran past the plot top. The stacked floor is now pinned to zero
(parts summing to a whole), independent of the requested minimum. Grouped
bars still honour an explicit minimum.
+- **Grouped bars emanate from the zero baseline.** A grouped (non-stacked) bar
+ measured its height from the axis nice-floor, so on an axis that crossed zero
+ a negative value rendered as a short upward column anchored at the floor —
+ visually indistinguishable from a small positive value — and positive bars
+ overshot below zero. Grouped bars now grow from the zero line (positive up,
+ negative hanging below it), matching the standard bar-chart convention and
+ the stacked-bar behaviour. When zero is off-scale — an explicit non-zero
+ `valueAxis().min(...)` or `baselineAtZero(false)` over a range that excludes
+ zero — the baseline clamps to the nearest visible bound, so a deliberately
+ zoomed axis still anchors its bars at the plot floor. Charts with positive
+ data on a zero-based axis are byte-identical.
- **`ChartStyle.paintForSeries` rejects a negative series index** with a
value-naming `IllegalArgumentException` instead of leaking a bare
`IndexOutOfBoundsException` from the palette modulo.
@@ -432,10 +443,13 @@ Entries land here as they merge.
two spaces per depth, per-depth custom markers survive, lists inside
sections export, empty lists are a no-op. Pagination: a keep-together
section taller than a full page still flows instead of relocating. Charts:
- negative bar values extend the axis below zero and measure from the nice
- floor, stacked bars skip non-positive segments, a one-point smooth/area
- line keeps its marker and label, long category labels stay slot-sized,
- tight-width legends keep every entry, all-negative `NiceScale` ranges.
+ negative grouped bars extend the axis below zero and hang from the zero
+ baseline (positive and negative bars meet at zero, heights proportional to
+ `|value|`), an explicit positive axis minimum anchors grouped bars at the
+ visible floor, stacked bars skip non-positive segments, a one-point
+ smooth/area line keeps its marker and label, long category labels stay
+ slot-sized, tight-width legends keep every entry, all-negative `NiceScale`
+ ranges.
## v1.7.1 — 2026-06-09
diff --git a/assets/readme/chart-showcase.png b/assets/readme/chart-showcase.png
index 92fb4f646..1349119f9 100644
Binary files a/assets/readme/chart-showcase.png and b/assets/readme/chart-showcase.png differ
diff --git a/assets/readme/examples/chart-showcase.pdf b/assets/readme/examples/chart-showcase.pdf
index fb2c612af..5c5cbbbe0 100644
Binary files a/assets/readme/examples/chart-showcase.pdf and b/assets/readme/examples/chart-showcase.pdf differ
diff --git a/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java b/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java
index 028dfde3b..72ead791a 100644
--- a/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java
+++ b/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java
@@ -68,6 +68,27 @@ public static Path generate() throws Exception {
.barCornerRadius(DocumentCornerRadius.top(2))
.build();
+ // Mixed positive / negative: bars emanate from the zero baseline, so a
+ // loss grows downward across zero instead of reading as a short upward
+ // stub. The axis extends below zero to make room for the negative bars.
+ ChartData netFlow = ChartData.builder()
+ .categories("Q1", "Q2", "Q3", "Q4")
+ .series("Net cash flow", 8.2, -3.5, 5.1, -1.8)
+ .build();
+
+ ChartSpec varianceSpec = ChartSpec.bar()
+ .data(netFlow)
+ .valueAxis(AxisSpec.builder()
+ .format(NumberFormatSpec.pattern("#,##0.0").withSuffix("k"))
+ .build())
+ .valueLabels(ValueLabelMode.OUTSIDE)
+ .size(ChartSize.aspectRatio(16, 7))
+ .build();
+
+ ChartStyle varianceStyle = ChartStyle.builder()
+ .seriesPaint(0, DocumentPaint.solid(DocumentColor.rgb(20, 80, 95)))
+ .build();
+
// Minimal chart: no grid, no axis tick labels, no category labels —
// only the bars and their value numbers (e.g. 12.4k).
ChartSpec minimalSpec = ChartSpec.bar()
@@ -179,6 +200,15 @@ public static Path generate() throws Exception {
.textStyle(THEME.text().h3())
.margin(DocumentInsets.zero()))
.chart(barSpec, barStyle))
+ .addSection("VarianceCard", section -> section
+ .keepTogether()
+ .softPanel(DocumentColor.WHITE, 8, 16)
+ .spacing(10)
+ .addParagraph(p -> p
+ .text("Net cash flow — bars emanate from zero, losses hang below")
+ .textStyle(THEME.text().h3())
+ .margin(DocumentInsets.zero()))
+ .chart(varianceSpec, varianceStyle))
.addSection("MinimalCard", section -> section
.keepTogether()
.softPanel(DocumentColor.WHITE, 8, 16)
diff --git a/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java b/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java
index 1014c2a0f..0689f268e 100644
--- a/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java
+++ b/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java
@@ -14,6 +14,15 @@
* Geometry for vertical and horizontal bar charts, grouped or stacked, with
* per-bar value labels and stacked-total labels.
*
+ *
Grouped bars emanate from the zero baseline — positive values grow away
+ * from zero, negative values hang back across it — matching the standard
+ * bar-chart convention. When zero falls outside the visible range (an explicit
+ * non-zero {@code valueAxis().min(...)} or {@code baselineAtZero(false)} over a
+ * range that does not include zero), the baseline clamps to the nearest visible
+ * bound, so a deliberately zoomed axis still anchors its bars at the plot edge.
+ * Stacked bars always sum upward from a zero floor (see
+ * {@link ChartLayoutSupport#computeFrame}).
+ *
* @author Artem Demchyshyn
* @since 1.8.0
*/
@@ -100,12 +109,21 @@ private static List resolveVertical(ChartSpec.Bar bar, ChartStyl
} else {
double barW = groupW / sCount;
double innerBarW = Math.max(0.5, barW * INNER_BAR_RATIO);
+ // Grouped bars emanate from the zero baseline: positive values
+ // grow up, negative values hang down. When zero is off-scale
+ // (an explicit positive min, or baselineAtZero(false) over a
+ // non-zero range), the baseline clamps to the nearest visible
+ // bound, so a zoomed axis still anchors bars at the plot floor.
+ double baseValue = baselineValue(f.scale());
+ double baseY = f.yForValue(baseValue);
for (int s = 0; s < sCount; s++) {
Double v = data.series().get(s).values().get(c);
if (v == null) {
continue;
}
- double h = f.scale().fractionOf(v) * f.plotHeight();
+ double valueY = f.yForValue(v);
+ double yBottom = Math.min(baseY, valueY);
+ double h = Math.abs(valueY - baseY);
if (h < MIN_BAR_HEIGHT) {
continue;
}
@@ -115,12 +133,16 @@ private static List resolveVertical(ChartSpec.Bar bar, ChartStyl
new ShapeNode("bar_c" + c + "_s" + s, innerBarW, h, null, null,
barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero(),
null, paint),
- bx, f.plotBottomY(), innerBarW, h));
+ bx, yBottom, innerBarW, h));
if (bar.valueLabels() == ValueLabelMode.OUTSIDE) {
String text = bar.valueAxis().format().format(v);
double labelW = Math.max(innerBarW, metrics.width(valueStyle, text) + 2.0);
+ // Label above a positive bar's top, below a negative bar's bottom.
+ double labelBottomY = v >= baseValue
+ ? yBottom + h + labelGap
+ : yBottom - labelGap - f.valueLineH();
emitChipLabel(out, "value_c" + c + "_s" + s, text, valueStyle, null,
- bx + innerBarW / 2.0, f.plotBottomY() + h + labelGap,
+ bx + innerBarW / 2.0, labelBottomY,
labelW, f.valueLineH());
}
}
@@ -287,12 +309,18 @@ private static List resolveHorizontal(ChartSpec.Bar bar, ChartSt
} else {
double barH = groupH / sCount;
double innerBarH = Math.max(0.5, barH * INNER_BAR_RATIO);
+ // Bars emanate from the zero baseline (clamped to the visible
+ // range): positive values extend right, negative values left.
+ double baseValue = baselineValue(scale);
+ double baseX = plotLeftX + scale.fractionOf(baseValue) * plotW;
for (int s = 0; s < sCount; s++) {
Double v = data.series().get(s).values().get(c);
if (v == null) {
continue;
}
- double w = scale.fractionOf(v) * plotW;
+ double valueX = plotLeftX + scale.fractionOf(v) * plotW;
+ double xLeft = Math.min(baseX, valueX);
+ double w = Math.abs(valueX - baseX);
if (w < MIN_BAR_HEIGHT) {
continue;
}
@@ -302,11 +330,16 @@ private static List resolveHorizontal(ChartSpec.Bar bar, ChartSt
new ShapeNode("bar_c" + c + "_s" + s, w, innerBarH, null, null,
barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero(),
null, paint),
- plotLeftX, barTop - innerBarH, w, innerBarH));
+ xLeft, barTop - innerBarH, w, innerBarH));
if (bar.valueLabels() == ValueLabelMode.OUTSIDE) {
- emitEndLabel(out, "value_c" + c + "_s" + s, axis.format().format(v),
- valueStyle, null, plotLeftX + w + labelGap,
- barTop - innerBarH / 2.0 - valueInk, valueLineH, metrics);
+ String text = axis.format().format(v);
+ double labelW = Math.max(8.0, metrics.width(valueStyle, text) + 2.0);
+ // Past the right end for positive, the left end for negative.
+ double centerX = v >= baseValue
+ ? xLeft + w + labelGap + labelW / 2.0
+ : xLeft - labelGap - labelW / 2.0;
+ emitChipLabel(out, "value_c" + c + "_s" + s, text, valueStyle, null,
+ centerX, barTop - innerBarH / 2.0 - valueInk, labelW, valueLineH);
}
}
}
@@ -323,6 +356,18 @@ private static List resolveHorizontal(ChartSpec.Bar bar, ChartSt
return out;
}
+ /**
+ * The value bars emanate from — zero, clamped into the visible axis range.
+ * With a normal zero-based axis this is exactly zero (bars sit on the
+ * floor); with an explicit non-zero min or {@code baselineAtZero(false)}
+ * that pushes zero off-scale, it clamps to the nearest visible bound so a
+ * zoomed axis anchors its bars at the plot edge instead of an invisible
+ * zero line.
+ */
+ private static double baselineValue(NiceScale scale) {
+ return Math.max(scale.niceMin(), Math.min(0.0, scale.niceMax()));
+ }
+
private static void emitEndLabel(List out, String name, String text,
DocumentTextStyle style, DocumentColor halo,
double x, double yBottom, double lineH,
diff --git a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java
index 6a9daaa5d..36ca28bad 100644
--- a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java
+++ b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java
@@ -234,7 +234,7 @@ void stackedBarsLabelTheCategoryTotal() {
}
@Test
- void groupedBarsWithNegativeValuesMeasureFromTheNiceFloor() {
+ void groupedBarsEmanateFromZeroAndNegativesHangBelow() {
ChartData data = ChartData.builder()
.categories("A", "B")
.series("S", 10.0, -2.0)
@@ -245,19 +245,110 @@ void groupedBarsWithNegativeValuesMeasureFromTheNiceFloor() {
bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
// Domain [-2, 10] with the zero baseline rounds to the nice range
- // [-5, 10]: the axis extends below zero and both bars anchor at the
- // -5 floor, so the negative bar renders as a short positive-height
- // column reaching its value level (no crash, no inverted geometry).
+ // [-5, 10]: the axis extends below zero. The positive bar grows up from
+ // the zero line; the negative bar hangs down across it. Both meet
+ // exactly at zero, and their heights are proportional to |value|.
ChartPrimitive positive = byName(out, "bar_c0_s0");
ChartPrimitive negative = byName(out, "bar_c1_s0");
- assertThat(negative.y()).isEqualTo(positive.y());
- // fractionOf(10) = 1.0 vs fractionOf(-2) = 0.2 over [-5, 10].
+ // Positive bar's bottom == negative bar's top == the zero line.
+ assertThat(positive.y()).isCloseTo(negative.y() + negative.height(), within(1e-9));
+ // The negative bar's body sits below where the positive one starts.
+ assertThat(negative.y()).isLessThan(positive.y());
+ // |−2| / |10| = 0.2.
assertThat(negative.height()).isCloseTo(positive.height() * 0.2, within(1e-9));
// The negative bound appears as a tick label.
assertThat(out.stream().anyMatch(p -> p.node() instanceof ParagraphNode pn
&& pn.name().startsWith("tick_") && "-5".equals(pn.text()))).isTrue();
}
+ @Test
+ void horizontalGroupedBarsEmanateFromZeroAndNegativesExtendLeft() {
+ ChartData data = ChartData.builder()
+ .categories("A", "B")
+ .series("S", 10.0, -2.0)
+ .build();
+ ChartSpec.Bar bar = ChartSpec.bar().data(data).horizontal(true).build();
+
+ List out = ChartLayoutResolver.resolve(
+ bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
+
+ ChartPrimitive positive = byName(out, "bar_c0_s0");
+ ChartPrimitive negative = byName(out, "bar_c1_s0");
+ // Positive bar starts at the zero line and runs right; the negative
+ // bar's right edge reaches the zero line and its body extends left.
+ assertThat(positive.x()).isCloseTo(negative.x() + negative.width(), within(1e-9));
+ assertThat(negative.x()).isLessThan(positive.x());
+ // Widths proportional to |value|: |−2| / |10| = 0.2.
+ assertThat(negative.width()).isCloseTo(positive.width() * 0.2, within(1e-9));
+ }
+
+ @Test
+ void groupedBarsWithAPositiveAxisMinAnchorAtThePlotFloor() {
+ // Zero is off-scale below an explicit positive min, so bars anchor at
+ // the visible floor (the standard "zoomed axis" reading) rather than an
+ // invisible zero line — the deliberate, tested behaviour for a min set.
+ ChartData data = ChartData.builder()
+ .categories("A", "B").series("S", 60.0, 80.0).build();
+ ChartSpec.Bar bar = ChartSpec.bar().data(data)
+ .valueAxis(AxisSpec.builder().min(50.0).build())
+ .build();
+
+ List out = ChartLayoutResolver.resolve(
+ bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
+
+ ChartPrimitive barA = byName(out, "bar_c0_s0");
+ ChartPrimitive barB = byName(out, "bar_c1_s0");
+ // Both bars share the floor baseline; the larger value is taller.
+ assertThat(barA.y()).isEqualTo(barB.y());
+ assertThat(barB.height()).isGreaterThan(barA.height());
+ }
+
+ @Test
+ void negativeBarValueLabelsSitOnTheOutsideOfTheBar() {
+ // OUTSIDE labels follow the bar's far end: a positive bar labels above
+ // its top, a negative bar below its bottom — never on the zero side.
+ ChartData data = ChartData.builder()
+ .categories("A", "B")
+ .series("S", 10.0, -2.0)
+ .build();
+ ChartSpec.Bar bar = ChartSpec.bar().data(data)
+ .valueLabels(ValueLabelMode.OUTSIDE).build();
+
+ List out = ChartLayoutResolver.resolve(
+ bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
+
+ ChartPrimitive posBar = byName(out, "bar_c0_s0");
+ ChartPrimitive posLabel = byName(out, "value_c0_s0");
+ ChartPrimitive negBar = byName(out, "bar_c1_s0");
+ ChartPrimitive negLabel = byName(out, "value_c1_s0");
+ // Positive label sits above the bar top; negative label below the bottom.
+ assertThat(posLabel.y()).isGreaterThanOrEqualTo(posBar.y() + posBar.height());
+ assertThat(negLabel.y() + negLabel.height()).isLessThanOrEqualTo(negBar.y());
+ }
+
+ @Test
+ void horizontalNegativeBarValueLabelsSitPastTheLeftEnd() {
+ // OUTSIDE labels on horizontal bars: positive past the right end,
+ // negative past the left end (the side away from zero).
+ ChartData data = ChartData.builder()
+ .categories("A", "B")
+ .series("S", 10.0, -2.0)
+ .build();
+ ChartSpec.Bar bar = ChartSpec.bar().data(data).horizontal(true)
+ .valueLabels(ValueLabelMode.OUTSIDE).build();
+
+ List out = ChartLayoutResolver.resolve(
+ bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
+
+ ChartPrimitive posBar = byName(out, "bar_c0_s0");
+ ChartPrimitive posLabel = byName(out, "value_c0_s0");
+ ChartPrimitive negBar = byName(out, "bar_c1_s0");
+ ChartPrimitive negLabel = byName(out, "value_c1_s0");
+ // Positive label past the right end; negative label past the left end.
+ assertThat(posLabel.x()).isGreaterThanOrEqualTo(posBar.x() + posBar.width());
+ assertThat(negLabel.x() + negLabel.width()).isLessThanOrEqualTo(negBar.x());
+ }
+
@Test
void stackedBarsSkipNonPositiveSegments() {
ChartData data = ChartData.builder().categories("A")