diff --git a/CHANGELOG.md b/CHANGELOG.md index adca5d7ef..3ad6057df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ Entries land here as they merge. ### Public API +- **Line-chart interpolation modes** (`@since 1.8.0`). New + `LineInterpolation` enum selects how a line series connects its points: + `LINEAR` (straight, exact), `SMOOTH` (the existing pretty Catmull-Rom + curve, which may overshoot local extremes on sharp swings), and the new + `MONOTONE` (Fritsch-Carlson) — a curve that looks just as smooth but is + constrained to never overshoot, staying within the value range of the + points it spans, for an accurate yet smooth reading of the data. Set it + with `ChartSpec.line().interpolation(LineInterpolation.MONOTONE)` — the + single, explicit knob for line shape. All three render through the same + native PDF curve operators with zero tessellation, so geometry stays + deterministic and the hot path is unchanged. - **`ChartData.Series` rejects non-finite values.** A `NaN` / ±∞ entry now fails at construction — naming the series and the offending index — instead of poisoning axis derivation and surfacing as a misleading diff --git a/assets/readme/chart-showcase.png b/assets/readme/chart-showcase.png index 1349119f9..d4a2d37c5 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 5c5cbbbe0..c91ab4cf6 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 72ead791a..9c3de4160 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 @@ -10,6 +10,7 @@ import com.demcha.compose.document.chart.ChartStyle; import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.chart.LegendPosition; +import com.demcha.compose.document.chart.LineInterpolation; import com.demcha.compose.document.chart.NumberFormatSpec; import com.demcha.compose.document.chart.PointMarker; import com.demcha.compose.document.chart.SliceLabelMode; @@ -123,12 +124,32 @@ public static Path generate() throws Exception { // Smooth area: Catmull-Rom curves with a translucent fill to the baseline. ChartSpec areaSpec = ChartSpec.line() .data(revenue) - .smooth(true) + .interpolation(LineInterpolation.SMOOTH) .area(true) .legend(LegendPosition.TOP) .size(ChartSize.aspectRatio(16, 7)) .build(); + // Accuracy vs. beauty on the same volatile series. SMOOTH (Catmull-Rom) + // is the prettiest curve but bulges past the peaks and troughs it + // connects; MONOTONE (Fritsch-Carlson) stays just as smooth yet never + // leaves the data's range — the curve never claims a value the data + // never had. Markers pin the true data points so the difference shows. + ChartData volatileSeries = ChartData.builder() + .categories("Jan", "Feb", "Mar", "Apr", "May", "Jun") + .series("Price", 20.0, 95.0, 92.0, 18.0, 24.0, 90.0) + .build(); + ChartSpec smoothSwingSpec = ChartSpec.line() + .data(volatileSeries) + .interpolation(LineInterpolation.SMOOTH) + .size(ChartSize.aspectRatio(16, 6)) + .build(); + ChartSpec monotoneSwingSpec = ChartSpec.line() + .data(volatileSeries) + .interpolation(LineInterpolation.MONOTONE) + .size(ChartSize.aspectRatio(16, 6)) + .build(); + // Horizontal bars: categories on Y, values on X, legend as a right column. ChartSpec horizontalSpec = ChartSpec.bar() .data(revenue) @@ -236,6 +257,24 @@ public static Path generate() throws Exception { .textStyle(THEME.text().h3()) .margin(DocumentInsets.zero())) .chart(areaSpec)) + .addSection("SmoothSwingCard", section -> section + .keepTogether() + .softPanel(DocumentColor.WHITE, 8, 16) + .spacing(10) + .addParagraph(p -> p + .text("Volatile price — SMOOTH curve (pretty, overshoots the peaks)") + .textStyle(THEME.text().h3()) + .margin(DocumentInsets.zero())) + .chart(smoothSwingSpec, lineStyle)) + .addSection("MonotoneSwingCard", section -> section + .keepTogether() + .softPanel(DocumentColor.WHITE, 8, 16) + .spacing(10) + .addParagraph(p -> p + .text("Volatile price — MONOTONE curve (smooth, never leaves the data range)") + .textStyle(THEME.text().h3()) + .margin(DocumentInsets.zero())) + .chart(monotoneSwingSpec, lineStyle)) .addSection("HorizontalCard", section -> section .keepTogether() .softPanel(DocumentColor.WHITE, 8, 16) diff --git a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckCharts.java b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckCharts.java index 0c48e8d85..74558cc34 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckCharts.java +++ b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckCharts.java @@ -14,6 +14,7 @@ import com.demcha.compose.document.chart.ChartSpec; import com.demcha.compose.document.chart.ChartStyle; import com.demcha.compose.document.chart.LegendPosition; +import com.demcha.compose.document.chart.LineInterpolation; import com.demcha.compose.document.chart.NumberFormatSpec; import com.demcha.compose.document.chart.PointMarker; import com.demcha.compose.document.chart.SliceLabelMode; @@ -145,7 +146,7 @@ static ChartSpec scalingLineChart(EngineDeckData.BenchRun b) { static ChartSpec memoryAreaChart(EngineDeckData.BenchRun b) { return ChartSpec.line() .data(bySize(b, false)) - .smooth(true) + .interpolation(LineInterpolation.SMOOTH) .area(true) .valueAxis(AxisSpec.builder().baselineAtZero(true) .format(NumberFormatSpec.pattern("#,##0").withSuffix(" MB")).build()) diff --git a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java index 0d575f65b..43a542a8b 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java @@ -9,6 +9,7 @@ import com.demcha.compose.document.chart.ChartSpec; import com.demcha.compose.document.chart.ChartStyle; import com.demcha.compose.document.chart.LegendPosition; +import com.demcha.compose.document.chart.LineInterpolation; import com.demcha.compose.document.chart.PointMarker; import com.demcha.compose.document.chart.SliceLabelMode; import com.demcha.compose.document.chart.ValueLabelMode; @@ -213,13 +214,13 @@ public static Path generate() throws Exception { feature(flow, "Line chart — smooth, area fill, markers, label halos", """ section.chart(ChartSpec.line().data(revenue) - .smooth(true).area(true).valueLabels(ValueLabelMode.OUTSIDE) + .interpolation(LineInterpolation.SMOOTH).area(true).valueLabels(ValueLabelMode.OUTSIDE) .size(ChartSize.fixedHeight(150)).build(), ChartStyle.builder().lineWidth(1.8) .pointMarker(PointMarker.circle(5).withStroke(DocumentStroke.of(WHITE, 1.2))) .build())""", demo -> demo.chart(ChartSpec.line().data(revenue) - .smooth(true).area(true).valueLabels(ValueLabelMode.OUTSIDE) + .interpolation(LineInterpolation.SMOOTH).area(true).valueLabels(ValueLabelMode.OUTSIDE) .size(ChartSize.fixedHeight(150)).build(), ChartStyle.builder().lineWidth(1.8) .pointMarker(PointMarker.circle(5) diff --git a/src/main/java/com/demcha/compose/document/chart/ChartSpec.java b/src/main/java/com/demcha/compose/document/chart/ChartSpec.java index 6e52aa2c3..b7821b6a6 100644 --- a/src/main/java/com/demcha/compose/document/chart/ChartSpec.java +++ b/src/main/java/com/demcha/compose/document/chart/ChartSpec.java @@ -250,7 +250,9 @@ public Bar build() { * from {@link ChartStyle#pointMarker()} so the geometry is reused). * * @param data tabular data - * @param smooth true = curved (Catmull-Rom) segments; false = straight + * @param interpolation how points are connected: {@link LineInterpolation#LINEAR} + * straight, {@link LineInterpolation#SMOOTH} pretty curve, + * {@link LineInterpolation#MONOTONE} smooth without overshoot * @param area fill the region between each series and the axis baseline * (translucent series colour; see {@code ChartStyle.areaOpacity}) * @param valueAxis numeric-axis configuration @@ -261,7 +263,7 @@ public Bar build() { */ record Line( ChartData data, - boolean smooth, + LineInterpolation interpolation, boolean area, AxisSpec valueAxis, LegendPosition legend, @@ -274,6 +276,7 @@ record Line( */ public Line { Objects.requireNonNull(data, "data"); + interpolation = interpolation == null ? LineInterpolation.LINEAR : interpolation; valueAxis = valueAxis == null ? AxisSpec.defaults() : valueAxis; legend = legend == null ? LegendPosition.NONE : legend; valueLabels = valueLabels == null ? ValueLabelMode.NONE : valueLabels; @@ -281,19 +284,19 @@ record Line( } /** - * Backward-compatible constructor without the area and category-label - * toggles (no area fill; category labels shown). + * Convenience constructor without the area and category-label toggles + * (no area fill; category labels shown). * - * @param data tabular data - * @param smooth curved segments - * @param valueAxis numeric-axis configuration - * @param legend legend placement - * @param valueLabels per-point value label mode - * @param size sizing policy + * @param data tabular data + * @param interpolation point-connection mode + * @param valueAxis numeric-axis configuration + * @param legend legend placement + * @param valueLabels per-point value label mode + * @param size sizing policy */ - public Line(ChartData data, boolean smooth, AxisSpec valueAxis, LegendPosition legend, - ValueLabelMode valueLabels, ChartSize size) { - this(data, smooth, false, valueAxis, legend, valueLabels, size, true); + public Line(ChartData data, LineInterpolation interpolation, AxisSpec valueAxis, + LegendPosition legend, ValueLabelMode valueLabels, ChartSize size) { + this(data, interpolation, false, valueAxis, legend, valueLabels, size, true); } @Override @@ -306,7 +309,7 @@ public NumberFormatSpec valueFormat() { */ public static final class Builder { private ChartData data; - private boolean smooth = false; + private LineInterpolation interpolation = LineInterpolation.LINEAR; private boolean area = false; private AxisSpec valueAxis = AxisSpec.defaults(); private LegendPosition legend = LegendPosition.NONE; @@ -326,16 +329,14 @@ public Builder data(ChartData d) { } /** - * Sets smoothing. Curves are Catmull-Rom splines subdivided at a - * fixed step, so geometry stays deterministic. Like any - * interpolating spline, a curve may slightly overshoot local - * extremes between data points on sharp value swings. + * Sets how the line connects its points; see + * {@link LineInterpolation} for the beauty-vs-accuracy trade-offs. * - * @param v true for curved segments + * @param mode point-connection mode * @return this builder */ - public Builder smooth(boolean v) { - this.smooth = v; + public Builder interpolation(LineInterpolation mode) { + this.interpolation = mode; return this; } @@ -412,7 +413,7 @@ public Builder size(ChartSize s) { * @return line spec */ public Line build() { - return new Line(data, smooth, area, valueAxis, legend, valueLabels, size, + return new Line(data, interpolation, area, valueAxis, legend, valueLabels, size, showCategoryLabels); } } diff --git a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java index 403206cfb..12ccc66f7 100644 --- a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java +++ b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java @@ -13,12 +13,14 @@ /** * Geometry for line charts: straight polylines or native cubic-Bézier * smoothed curves, optional translucent area fills (curved to match in - * smooth mode), point markers, and collision-aware value labels. + * curved modes), point markers, and collision-aware value labels. * - *

Smooth runs compile into a single {@code PathNode} per run whose - * Catmull-Rom-derived control points are pure arithmetic on the data points - * — the exact continuous curve the pre-1.8 fixed-step sampler approximated, - * now rendered with native PDF curve operators and zero tessellation.

+ *

Curved runs ({@link LineInterpolation#SMOOTH} or + * {@link LineInterpolation#MONOTONE}) compile into a single {@code PathNode} + * per run whose control points are pure arithmetic on the data points — + * Catmull-Rom for the pretty (possibly overshooting) curve, Fritsch-Carlson + * for the monotone curve that never overshoots — rendered with native PDF + * curve operators and zero tessellation.

* * @author Artem Demchyshyn * @since 1.8.0 @@ -54,16 +56,17 @@ static List resolve(ChartSpec.Line line, ChartStyle style, double strokeWidth = style.lineWidth() == null ? DEFAULT_LINE_WIDTH : style.lineWidth(); double areaOpacity = style.areaOpacity() == null ? DEFAULT_AREA_OPACITY : style.areaOpacity(); - // Per-series contiguous non-null runs of original data points. Smooth - // mode compiles each run into native Bézier primitives; markers and + // Per-series contiguous non-null runs of original data points. Curved + // modes compile each run into native Bézier primitives; markers and // labels stay on the original data points either way. List>> seriesRuns = new ArrayList<>(); for (int s = 0; s < data.seriesCount(); s++) { seriesRuns.add(sampleSeries(data.series().get(s), f, slotW)); } - boolean smooth = line.smooth(); + LineInterpolation interpolation = line.interpolation(); + boolean curved = interpolation != LineInterpolation.LINEAR; - // Pass 0 — area fills, under every stroke. Smooth runs close the + // Pass 0 — area fills, under every stroke. Curved runs close the // exact stroke curve down to the baseline so fill and stroke edges // coincide. if (line.area()) { @@ -77,8 +80,9 @@ static List resolve(ChartSpec.Line line, ChartStyle style, continue; } String name = "area_s" + s + "_r" + runIndex; - if (smooth && run.size() >= 3) { - emitCurvedArea(out, name, run, f.plotBottomY(), fill); + if (curved && run.size() >= 3) { + emitCurvedArea(out, name, run, curveControls(run, interpolation), + f.plotBottomY(), fill); } else { emitAreaPolygon(out, name, run, f.plotBottomY(), fill); } @@ -87,7 +91,7 @@ static List resolve(ChartSpec.Line line, ChartStyle style, } } - // Pass 1 — series strokes: one native Bézier path per smooth run + // Pass 1 — series strokes: one native Bézier path per curved run // (three or more points), straight segments otherwise. for (int s = 0; s < data.seriesCount(); s++) { DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); @@ -95,8 +99,9 @@ static List resolve(ChartSpec.Line line, ChartStyle style, int n = 0; int runIndex = 0; for (List run : seriesRuns.get(s)) { - if (smooth && run.size() >= 3) { - out.add(bezierRun("line_s" + s + "_curve" + runIndex, run, stroke)); + if (curved && run.size() >= 3) { + out.add(bezierRun("line_s" + s + "_curve" + runIndex, run, + curveControls(run, interpolation), stroke)); } else { for (int i = 1; i < run.size(); i++) { out.add(segment("line_s" + s + "_seg" + n++, @@ -174,6 +179,19 @@ private static List> sampleSeries(ChartData.Series series, return runs; } + /** + * Cubic-Bézier control points for every span of a run, selected by the + * interpolation mode. {@link LineInterpolation#SMOOTH} yields the pretty + * Catmull-Rom curve (may overshoot); {@link LineInterpolation#MONOTONE} + * yields the Fritsch-Carlson curve constrained to the data's range. + * {@code LINEAR} never reaches here (handled as straight segments upstream). + */ + private static List curveControls(List run, LineInterpolation mode) { + return mode == LineInterpolation.MONOTONE + ? monotoneControls(run) + : catmullRomControls(run); + } + /** * Uniform Catmull-Rom control points (tension 0.5, clamped endpoints) * for every span of a run: {@code c1 = p1 + (p2 - p0) / 6}, @@ -195,14 +213,68 @@ private static List catmullRomControls(List points) { return controls; } + /** + * Monotone cubic (Fritsch-Carlson) control points for every span of a run. + * The tangent at each point is the average of the adjacent secant slopes, + * forced to zero at local extrema and then rescaled by the Fritsch-Carlson + * factor so the resulting cubic stays monotone on every span. The curve + * therefore never overshoots — each span stays within the value range of + * its two endpoints and preserves the data's rises and falls — unlike the + * Catmull-Rom curve. Returns one {@code [c1, c2]} pair per span. + */ + private static List monotoneControls(List points) { + int n = points.size(); + double[] secant = new double[n - 1]; + for (int i = 0; i < n - 1; i++) { + double dx = points.get(i + 1)[0] - points.get(i)[0]; + secant[i] = dx == 0.0 ? 0.0 : (points.get(i + 1)[1] - points.get(i)[1]) / dx; + } + double[] tangent = new double[n]; + tangent[0] = secant[0]; + tangent[n - 1] = secant[n - 2]; + for (int i = 1; i < n - 1; i++) { + // Opposite-signed (or zero) neighbouring slopes mark a local + // extremum; a flat tangent there is what prevents the overshoot. + tangent[i] = secant[i - 1] * secant[i] <= 0.0 + ? 0.0 : (secant[i - 1] + secant[i]) / 2.0; + } + // Fritsch-Carlson: clamp each tangent pair into the circle of radius 3 + // that guarantees a monotone span (alpha^2 + beta^2 <= 9). + for (int i = 0; i < n - 1; i++) { + if (secant[i] == 0.0) { + tangent[i] = 0.0; + tangent[i + 1] = 0.0; + continue; + } + double alpha = tangent[i] / secant[i]; + double beta = tangent[i + 1] / secant[i]; + double s = alpha * alpha + beta * beta; + if (s > 9.0) { + double tau = 3.0 / Math.sqrt(s); + tangent[i] = tau * alpha * secant[i]; + tangent[i + 1] = tau * beta * secant[i]; + } + } + List controls = new ArrayList<>(n - 1); + for (int i = 0; i < n - 1; i++) { + double[] p1 = points.get(i); + double[] p2 = points.get(i + 1); + double third = (p2[0] - p1[0]) / 3.0; + double[] c1 = {p1[0] + third, p1[1] + tangent[i] * third}; + double[] c2 = {p2[0] - third, p2[1] - tangent[i + 1] * third}; + controls.add(new double[][]{c1, c2}); + } + return controls; + } + /** * One stroked native-Bézier {@code PathNode} primitive covering a whole - * smooth run. The box is the bounding box of the data points and every - * control point, so normalized coordinates stay within the unit box by - * construction. + * curved run, from precomputed control points. The box is the bounding box + * of the data points and every control point, so normalized coordinates + * stay within the unit box by construction. */ - private static ChartPrimitive bezierRun(String name, List run, DocumentStroke stroke) { - List controls = catmullRomControls(run); + private static ChartPrimitive bezierRun(String name, List run, + List controls, DocumentStroke stroke) { double minX = Double.POSITIVE_INFINITY; double maxX = Double.NEGATIVE_INFINITY; double minY = Double.POSITIVE_INFINITY; @@ -241,14 +313,13 @@ private static ChartPrimitive bezierRun(String name, List run, Documen } /** - * Curved area fill for a smooth run: the exact stroke curve closed down - * to the plot baseline with two straight edges, emitted as one filled - * {@code PathNode}. + * Curved area fill for a curved run: the exact stroke curve (from the same + * precomputed control points) closed down to the plot baseline with two + * straight edges, emitted as one filled {@code PathNode}. */ private static void emitCurvedArea(List out, String name, - List run, double baselineY, - DocumentColor fill) { - List controls = catmullRomControls(run); + List run, List controls, + double baselineY, DocumentColor fill) { double minX = Double.POSITIVE_INFINITY; double maxX = Double.NEGATIVE_INFINITY; double minY = baselineY; diff --git a/src/main/java/com/demcha/compose/document/chart/LineInterpolation.java b/src/main/java/com/demcha/compose/document/chart/LineInterpolation.java new file mode 100644 index 000000000..f9dc19f2a --- /dev/null +++ b/src/main/java/com/demcha/compose/document/chart/LineInterpolation.java @@ -0,0 +1,37 @@ +package com.demcha.compose.document.chart; + +/** + * How a line-chart series connects its data points — the trade-off between a + * pleasing curve and a faithful reading of the numbers. + * + *

All three modes pass through every data point and render with the + * same native PDF operators (straight {@code lineTo} or cubic {@code cubicTo}); + * they differ only in the geometry between points.

+ * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public enum LineInterpolation { + /** + * Straight segments between consecutive points. Exact by construction — the + * line is literally the data, nothing is interpolated or embellished — at + * the cost of angular joints. + */ + LINEAR, + /** + * Smooth Catmull-Rom curve (tension 0.5). The prettiest option, but an + * interpolating spline may overshoot local extremes between points on + * sharp value swings, so the curve can briefly leave the range of the data + * it connects. Choose this when visual flow matters more than a precise + * reading; choose {@link #MONOTONE} when it does not. + */ + SMOOTH, + /** + * Smooth monotone cubic (Fritsch-Carlson) curve. Looks nearly as smooth as + * {@link #SMOOTH} but is constrained to never overshoot: the curve + * stays within the value range of the two points it spans and preserves the + * data's rises and falls. The accurate-yet-smooth middle ground — smooth + * presentation without misrepresenting the numbers. + */ + MONOTONE +} 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 36ca28bad..15e569ce1 100644 --- a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java +++ b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java @@ -380,7 +380,7 @@ void onePointLineEmitsItsMarkerButNoSegments() { // smooth + area exercise the interpolation and fill paths with a // single sample as well. ChartSpec.Line line = ChartSpec.line().data(data) - .smooth(true).area(true) + .interpolation(LineInterpolation.SMOOTH).area(true) .valueLabels(ValueLabelMode.OUTSIDE).build(); List out = ChartLayoutResolver.resolve( @@ -476,7 +476,7 @@ void areaFillsRenderUnderEveryStroke() { void smoothLineEmitsOneNativeBezierRun() { ChartData data = ChartData.builder().categories("A", "B", "C") .series("S", 1.0, 3.0, 2.0).build(); - ChartSpec.Line line = ChartSpec.line().data(data).smooth(true).build(); + ChartSpec.Line line = ChartSpec.line().data(data).interpolation(LineInterpolation.SMOOTH).build(); List out = ChartLayoutResolver.resolve( line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); @@ -502,7 +502,7 @@ void smoothLineEmitsOneNativeBezierRun() { void smoothAreaClosesTheExactCurveDownToTheBaseline() { ChartData data = ChartData.builder().categories("A", "B", "C") .series("S", 1.0, 3.0, 2.0).build(); - ChartSpec.Line line = ChartSpec.line().data(data).smooth(true).area(true).build(); + ChartSpec.Line line = ChartSpec.line().data(data).interpolation(LineInterpolation.SMOOTH).area(true).build(); List out = ChartLayoutResolver.resolve( line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); @@ -537,7 +537,7 @@ void smoothAreaClosesTheExactCurveDownToTheBaseline() { void twoPointSmoothRunFallsBackToAStraightSegment() { ChartData data = ChartData.builder().categories("A", "B") .series("S", 1.0, 3.0).build(); - ChartSpec.Line line = ChartSpec.line().data(data).smooth(true).build(); + ChartSpec.Line line = ChartSpec.line().data(data).interpolation(LineInterpolation.SMOOTH).build(); List out = ChartLayoutResolver.resolve( line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); @@ -548,6 +548,63 @@ void twoPointSmoothRunFallsBackToAStraightSegment() { .isTrue(); } + @Test + void monotoneLineEmitsOneNativeBezierRun() { + ChartData data = ChartData.builder().categories("A", "B", "C") + .series("S", 1.0, 3.0, 2.0).build(); + ChartSpec.Line line = ChartSpec.line().data(data) + .interpolation(LineInterpolation.MONOTONE).build(); + + List out = ChartLayoutResolver.resolve( + line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); + + // Like SMOOTH, MONOTONE is one native cubic path — no tessellation. + assertThat(out.stream().noneMatch(p -> p.node().name().startsWith("line_s0_seg"))) + .isTrue(); + com.demcha.compose.document.node.PathNode curve = + (com.demcha.compose.document.node.PathNode) byName(out, "line_s0_curve0").node(); + assertThat(curve.segments()).hasSize(3); + assertThat(curve.segments().get(0)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.MoveTo.class); + assertThat(curve.segments().get(1)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.CubicTo.class); + assertThat(curve.stroke()).isNotNull(); + } + + @Test + void monotoneCurveStaysWithinTheDataRangeWhileSmoothOvershoots() { + // A rise into a flat plateau: Catmull-Rom overshoots above the plateau, + // the monotone curve must not. y grows up, so "above" = larger y. + ChartData data = ChartData.builder().categories("A", "B", "C", "D") + .series("S", 10.0, 80.0, 80.0, 10.0).build(); + + // Straight segments trace the data vertices exactly — the ground-truth + // value range every curve must respect at its data points. + List linear = ChartLayoutResolver.resolve( + ChartSpec.line().data(data).interpolation(LineInterpolation.LINEAR).build(), + baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); + double dataTop = linear.stream() + .filter(p -> p.node().name().startsWith("line_s0_seg")) + .mapToDouble(p -> p.y() + p.height()).max().orElseThrow(); + double dataBottom = linear.stream() + .filter(p -> p.node().name().startsWith("line_s0_seg")) + .mapToDouble(ChartPrimitive::y).min().orElseThrow(); + + ChartPrimitive monotone = byName(ChartLayoutResolver.resolve( + ChartSpec.line().data(data).interpolation(LineInterpolation.MONOTONE).build(), + baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS), "line_s0_curve0"); + ChartPrimitive smooth = byName(ChartLayoutResolver.resolve( + ChartSpec.line().data(data).interpolation(LineInterpolation.SMOOTH).build(), + baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS), "line_s0_curve0"); + + double eps = 0.5; + // Monotone: the whole curve (its bounding box) stays inside the data range. + assertThat(monotone.y() + monotone.height()).isLessThanOrEqualTo(dataTop + eps); + assertThat(monotone.y()).isGreaterThanOrEqualTo(dataBottom - eps); + // Smooth: the pretty curve bulges above the plateau — that is the overshoot. + assertThat(smooth.y() + smooth.height()).isGreaterThan(dataTop + eps); + } + @Test void rightLegendReservesAColumnOutsideThePlot() { ChartData data = ChartData.builder().categories("A", "B") diff --git a/src/test/java/com/demcha/compose/document/dsl/ChartLayoutSnapshotTest.java b/src/test/java/com/demcha/compose/document/dsl/ChartLayoutSnapshotTest.java index 4614e79a6..5927e4d8b 100644 --- a/src/test/java/com/demcha/compose/document/dsl/ChartLayoutSnapshotTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/ChartLayoutSnapshotTest.java @@ -68,6 +68,25 @@ void lineMatchesLayoutSnapshot() throws Exception { } } + @Test + void monotoneLineMatchesLayoutSnapshot() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(360, 260) + .margin(DocumentInsets.of(20)) + .create()) { + ChartSpec spec = ChartSpec.line() + .data(revenue()) + .interpolation(com.demcha.compose.document.chart.LineInterpolation.MONOTONE) + .valueAxis(AxisSpec.builder().baselineAtZero(true).build()) + .legend(LegendPosition.BOTTOM) + .size(ChartSize.fixedHeight(150)) + .build(); + document.pageFlow().name("ChartMonotoneFixture").chart(spec).build(); + + LayoutSnapshotAssertions.assertMatches(document, "charts/line_monotone"); + } + } + @Test void pieChartLowersIntoPolygonFragments() { try (DocumentSession document = GraphCompose.document() diff --git a/src/test/resources/layout-snapshots/charts/line_monotone.json b/src/test/resources/layout-snapshots/charts/line_monotone.json new file mode 100644 index 000000000..290b6ffd9 --- /dev/null +++ b/src/test/resources/layout-snapshots/charts/line_monotone.json @@ -0,0 +1,77 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 360.0, + "pageHeight" : 260.0, + "innerWidth" : 320.0, + "innerHeight" : 220.0, + "margin" : { + "top" : 20.0, + "right" : 20.0, + "bottom" : 20.0, + "left" : 20.0 + } + }, + "totalPages" : 1, + "nodes" : [ { + "path" : "ChartMonotoneFixture[0]", + "entityName" : "ChartMonotoneFixture", + "entityKind" : "ContainerNode", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 20.0, + "computedY" : 90.0, + "placementX" : 20.0, + "placementY" : 90.0, + "placementWidth" : 320.0, + "placementHeight" : 150.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 150.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ChartMonotoneFixture[0]/Chart[0]", + "entityName" : null, + "entityKind" : "Chart", + "parentPath" : "ChartMonotoneFixture[0]", + "childIndex" : 0, + "depth" : 2, + "layer" : 2, + "computedX" : 20.0, + "computedY" : 90.0, + "placementX" : 20.0, + "placementY" : 90.0, + "placementWidth" : 320.0, + "placementHeight" : 150.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 150.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +}