@@ -195,6 +195,122 @@ void stackedBarsLabelTheCategoryTotal() {
195195 assertThat (total .text ()).isEqualTo ("30" );
196196 }
197197
198+ @ Test
199+ void groupedBarsWithNegativeValuesMeasureFromTheNiceFloor () {
200+ ChartData data = ChartData .builder ()
201+ .categories ("A" , "B" )
202+ .series ("S" , 10.0 , -2.0 )
203+ .build ();
204+ ChartSpec .Bar bar = ChartSpec .bar ().data (data ).build ();
205+
206+ List <ChartPrimitive > out = ChartLayoutResolver .resolve (
207+ bar , baseStyle (), ChartDefaults .DEFAULT_THEME , 200.0 , 100.0 , METRICS );
208+
209+ // Domain [-2, 10] with the zero baseline rounds to the nice range
210+ // [-5, 10]: the axis extends below zero and both bars anchor at the
211+ // -5 floor, so the negative bar renders as a short positive-height
212+ // column reaching its value level (no crash, no inverted geometry).
213+ ChartPrimitive positive = byName (out , "bar_c0_s0" );
214+ ChartPrimitive negative = byName (out , "bar_c1_s0" );
215+ assertThat (negative .y ()).isEqualTo (positive .y ());
216+ // fractionOf(10) = 1.0 vs fractionOf(-2) = 0.2 over [-5, 10].
217+ assertThat (negative .height ()).isCloseTo (positive .height () * 0.2 , within (1e-9 ));
218+ // The negative bound appears as a tick label.
219+ assertThat (out .stream ().anyMatch (p -> p .node () instanceof ParagraphNode pn
220+ && pn .name ().startsWith ("tick_" ) && "-5" .equals (pn .text ()))).isTrue ();
221+ }
222+
223+ @ Test
224+ void stackedBarsSkipNonPositiveSegments () {
225+ ChartData data = ChartData .builder ().categories ("A" )
226+ .series ("Up" , 5.0 )
227+ .series ("Down" , -3.0 )
228+ .series ("Zero" , 0.0 )
229+ .build ();
230+ ChartSpec .Bar bar = ChartSpec .bar ().data (data )
231+ .grouping (BarGrouping .STACKED ).valueLabels (ValueLabelMode .OUTSIDE ).build ();
232+
233+ List <ChartPrimitive > out = ChartLayoutResolver .resolve (
234+ bar , baseStyle (), ChartDefaults .DEFAULT_THEME , 200.0 , 120.0 , METRICS );
235+
236+ // Only the positive segment stacks; negative and zero segments are
237+ // skipped and the category total counts the positives alone.
238+ assertThat (out .stream ().filter (p -> p .node ().name ().startsWith ("bar_c0_" )).count ())
239+ .isEqualTo (1 );
240+ byName (out , "bar_c0_s0" );
241+ ParagraphNode total = (ParagraphNode ) byName (out , "total_c0" ).node ();
242+ assertThat (total .text ()).isEqualTo ("5" );
243+ }
244+
245+ @ Test
246+ void onePointLineEmitsItsMarkerButNoSegments () {
247+ ChartData data = ChartData .builder ().categories ("A" ).series ("S" , 7.0 ).build ();
248+ ChartStyle style = baseStyle ().mergedUnder (ChartStyle .builder ()
249+ .pointMarker (PointMarker .circle (5.0 ))
250+ .build ());
251+ // smooth + area exercise the interpolation and fill paths with a
252+ // single sample as well.
253+ ChartSpec .Line line = ChartSpec .line ().data (data )
254+ .smooth (true ).area (true )
255+ .valueLabels (ValueLabelMode .OUTSIDE ).build ();
256+
257+ List <ChartPrimitive > out = ChartLayoutResolver .resolve (
258+ line , style , ChartDefaults .DEFAULT_THEME , 200.0 , 100.0 , METRICS );
259+
260+ // No connecting stroke exists, but the lone point still gets its
261+ // marker and value label.
262+ assertThat (out .stream ().noneMatch (p -> p .node ().name ().startsWith ("line_s0_seg" )))
263+ .isTrue ();
264+ byName (out , "point_s0_c0" );
265+ ParagraphNode label = (ParagraphNode ) byName (out , "value_s0_c0" ).node ();
266+ assertThat (label .text ()).isEqualTo ("7" );
267+ }
268+
269+ @ Test
270+ void veryLongCategoryLabelsKeepTheirSlotWidth () {
271+ ChartData data = ChartData .builder ()
272+ .categories ("Short" , "An extremely long category label that far exceeds the slot" )
273+ .series ("S" , 10.0 , 20.0 )
274+ .build ();
275+ ChartSpec .Bar bar = ChartSpec .bar ().data (data ).build ();
276+
277+ List <ChartPrimitive > out = ChartLayoutResolver .resolve (
278+ bar , baseStyle (), ChartDefaults .DEFAULT_THEME , 200.0 , 100.0 , METRICS );
279+
280+ // Category labels are slot-sized: a long text wraps/centres inside
281+ // its category slot instead of widening the geometry.
282+ ChartPrimitive shortLabel = byName (out , "cat_0" );
283+ ChartPrimitive longLabel = byName (out , "cat_1" );
284+ assertThat (longLabel .width ()).isEqualTo (shortLabel .width ());
285+ assertThat (longLabel .x ()).isGreaterThan (shortLabel .x ());
286+ }
287+
288+ @ Test
289+ void bottomLegendInTightWidthStillEmitsEveryEntry () {
290+ ChartData data = ChartData .builder ().categories ("A" )
291+ .series ("First series with a long name" , 1.0 )
292+ .series ("Second series with a long name" , 2.0 )
293+ .series ("Third series with a long name" , 3.0 )
294+ .series ("Fourth series with a long name" , 4.0 )
295+ .series ("Fifth series with a long name" , 5.0 )
296+ .build ();
297+ ChartSpec .Bar bar = ChartSpec .bar ().data (data )
298+ .legend (LegendPosition .BOTTOM ).build ();
299+
300+ // 120pt is far too narrow for five long legend entries: layout must
301+ // stay deterministic and keep every entry (overflow, not loss).
302+ List <ChartPrimitive > out = ChartLayoutResolver .resolve (
303+ bar , baseStyle (), ChartDefaults .DEFAULT_THEME , 120.0 , 100.0 , METRICS );
304+
305+ double previousX = Double .NEGATIVE_INFINITY ;
306+ for (int s = 0 ; s < 5 ; s ++) {
307+ byName (out , "legend_swatch_" + s );
308+ ChartPrimitive label = byName (out , "legend_label_" + s );
309+ assertThat (label .x ()).isGreaterThan (previousX );
310+ previousX = label .x ();
311+ }
312+ }
313+
198314 @ Test
199315 void areaFillsRenderUnderEveryStroke () {
200316 ChartData data = ChartData .builder ().categories ("A" , "B" , "C" )
0 commit comments