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
+ }
+ } ]
+}