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")