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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Binary file modified assets/readme/chart-showcase.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/readme/examples/chart-showcase.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
* Geometry for vertical and horizontal bar charts, grouped or stacked, with
* per-bar value labels and stacked-total labels.
*
* <p>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
*/
Expand Down Expand Up @@ -100,12 +109,21 @@ private static List<ChartPrimitive> 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;
}
Expand All @@ -115,12 +133,16 @@ private static List<ChartPrimitive> 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());
}
}
Expand Down Expand Up @@ -287,12 +309,18 @@ private static List<ChartPrimitive> 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;
}
Expand All @@ -302,11 +330,16 @@ private static List<ChartPrimitive> 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);
}
}
}
Expand All @@ -323,6 +356,18 @@ private static List<ChartPrimitive> 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<ChartPrimitive> out, String name, String text,
DocumentTextStyle style, DocumentColor halo,
double x, double yBottom, double lineH,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ void stackedBarsLabelTheCategoryTotal() {
}

@Test
void groupedBarsWithNegativeValuesMeasureFromTheNiceFloor() {
void groupedBarsEmanateFromZeroAndNegativesHangBelow() {
ChartData data = ChartData.builder()
.categories("A", "B")
.series("S", 10.0, -2.0)
Expand All @@ -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<ChartPrimitive> 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<ChartPrimitive> 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<ChartPrimitive> 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<ChartPrimitive> 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")
Expand Down