diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7140d9..f9521442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,10 +30,12 @@ Entries land here as they merge. placement.** `ChartSpec.bar().horizontal(true)` transposes the chart (categories on Y in reading order, value axis on X, labels at bar ends); stacked bars label the category total. `ChartSpec.line().smooth(true)` - draws deterministic Catmull-Rom curves (fixed 8 sub-segments per span); - `.area(true)` fills each series down to the baseline with a translucent - series colour (`ChartStyle.areaOpacity`, default 0.35) — alpha-blended - fills layer legibly. `LegendPosition.TOP` and `RIGHT` now lay out as a top + draws deterministic Catmull-Rom curves as **native cubic Béziers** through + the vector path primitive — one `PathNode` per run, perfectly smooth at + any zoom level, zero tessellation; `.area(true)` fills each series down to + the baseline with a translucent series colour (`ChartStyle.areaOpacity`, + default 0.35) — alpha-blended fills layer legibly, and in smooth mode the + fill closes the exact stroke curve so fill and stroke edges coincide. `LegendPosition.TOP` and `RIGHT` now lay out as a top strip / right column for every chart kind, including pie. The chart resolver is split per kind (`BarChartLayout` / `LineChartLayout` / `PieChartLayout` over a shared `ChartLayoutSupport`). diff --git a/assets/readme/examples/chart-showcase.pdf b/assets/readme/examples/chart-showcase.pdf index 5c0aa91a..96e0e4d5 100644 Binary files a/assets/readme/examples/chart-showcase.pdf and b/assets/readme/examples/chart-showcase.pdf differ diff --git a/assets/readme/examples/feature-catalog.pdf b/assets/readme/examples/feature-catalog.pdf index c7c7bad7..67266505 100644 Binary files a/assets/readme/examples/feature-catalog.pdf and b/assets/readme/examples/feature-catalog.pdf differ diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java index 080d8509..119566b4 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java @@ -13,6 +13,7 @@ * operators — curves stay smooth at any zoom level. * * @author Artem Demchyshyn + * @since 1.8.0 */ public final class PdfPathFragmentRenderHandler implements PdfFragmentRenderHandler { diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java index f2d8bc02..b4a4d5a2 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java @@ -12,6 +12,7 @@ * Renders fixed polygon fragments (diamond, triangle, star, arbitrary rings). * * @author Artem Demchyshyn + * @since 1.8.0 */ public final class PdfPolygonFragmentRenderHandler implements PdfFragmentRenderHandler { 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 cc063972..93195d1f 100644 --- a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java +++ b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.chart; import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.node.PolygonNode; import com.demcha.compose.document.style.*; @@ -10,19 +11,20 @@ import static com.demcha.compose.document.chart.ChartLayoutSupport.*; /** - * Geometry for line charts: straight or Catmull-Rom-smoothed polylines, - * optional translucent area fills, point markers, and collision-aware value - * labels. + * 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. + * + *

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.

* * @author Artem Demchyshyn * @since 1.8.0 */ final class LineChartLayout { - /** - * Sub-segments per Catmull-Rom span; fixed so geometry stays deterministic. - */ - private static final int SMOOTH_SUBDIVISIONS = 8; private static final double DEFAULT_AREA_OPACITY = 0.35; private LineChartLayout() { @@ -52,43 +54,57 @@ 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 sampled polylines: contiguous non-null runs, optionally - // Catmull-Rom smoothed. Samples drive area fills and stroke segments; - // markers and labels stay on the original data points. - List>> sampledRuns = new ArrayList<>(); + // Per-series contiguous non-null runs of original data points. Smooth + // mode compiles 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++) { - sampledRuns.add(sampleSeries(data.series().get(s), f, slotW, line.smooth())); + seriesRuns.add(sampleSeries(data.series().get(s), f, slotW)); } + boolean smooth = line.smooth(); - // Pass 0 — area fills, under every stroke. + // Pass 0 — area fills, under every stroke. Smooth runs close the + // exact stroke curve down to the baseline so fill and stroke edges + // coincide. if (line.area()) { for (int s = 0; s < data.seriesCount(); s++) { DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); DocumentColor fill = color.withOpacity(areaOpacity); int runIndex = 0; - for (List run : sampledRuns.get(s)) { + for (List run : seriesRuns.get(s)) { if (run.size() < 2) { runIndex++; continue; } - emitAreaPolygon(out, "area_s" + s + "_r" + runIndex, run, - f.plotBottomY(), fill); + String name = "area_s" + s + "_r" + runIndex; + if (smooth && run.size() >= 3) { + emitCurvedArea(out, name, run, f.plotBottomY(), fill); + } else { + emitAreaPolygon(out, name, run, f.plotBottomY(), fill); + } runIndex++; } } } - // Pass 1 — every series' stroke segments. + // Pass 1 — series strokes: one native Bézier path per smooth run + // (three or more points), straight segments otherwise. for (int s = 0; s < data.seriesCount(); s++) { DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); DocumentStroke stroke = DocumentStroke.of(color, strokeWidth); int n = 0; - for (List run : sampledRuns.get(s)) { - for (int i = 1; i < run.size(); i++) { - out.add(segment("line_s" + s + "_seg" + n++, - run.get(i - 1)[0], run.get(i - 1)[1], - run.get(i)[0], run.get(i)[1], stroke)); + int runIndex = 0; + for (List run : seriesRuns.get(s)) { + if (smooth && run.size() >= 3) { + out.add(bezierRun("line_s" + s + "_curve" + runIndex, run, stroke)); + } else { + for (int i = 1; i < run.size(); i++) { + out.add(segment("line_s" + s + "_seg" + n++, + run.get(i - 1)[0], run.get(i - 1)[1], + run.get(i)[0], run.get(i)[1], stroke)); + } } + runIndex++; } } @@ -133,18 +149,18 @@ static List resolve(ChartSpec.Line line, ChartStyle style, } /** - * Splits a series into contiguous non-null runs of (x, y) samples. + * Splits a series into contiguous non-null runs of (x, y) data points. */ private static List> sampleSeries(ChartData.Series series, ChartLayoutSupport.Frame f, - double slotW, boolean smooth) { + double slotW) { List> runs = new ArrayList<>(); List current = new ArrayList<>(); for (int c = 0; c < series.values().size(); c++) { Double v = series.values().get(c); if (v == null) { if (!current.isEmpty()) { - runs.add(smooth ? smoothRun(current) : current); + runs.add(current); current = new ArrayList<>(); } continue; @@ -153,44 +169,128 @@ private static List> sampleSeries(ChartData.Series series, f.plotLeftX() + (c + 0.5) * slotW, f.yForValue(v)}); } if (!current.isEmpty()) { - runs.add(smooth ? smoothRun(current) : current); + runs.add(current); } return runs; } /** - * Subdivides a run with a centripetal-style Catmull-Rom spline (uniform - * parameterisation, clamped endpoints) into {@link #SMOOTH_SUBDIVISIONS} - * sub-segments per span. Pure arithmetic on the input points. + * Uniform Catmull-Rom control points (tension 0.5, clamped endpoints) + * for every span of a run: {@code c1 = p1 + (p2 - p0) / 6}, + * {@code c2 = p2 - (p3 - p1) / 6}. Pure arithmetic on the data points — + * the exact continuous curve the pre-1.8 fixed-step sampler approximated. + * Returns one {@code [c1, c2]} pair per span. */ - private static List smoothRun(List points) { - if (points.size() < 3) { - return points; - } - List samples = new ArrayList<>(); - samples.add(points.get(0)); + private static List catmullRomControls(List points) { + List controls = new ArrayList<>(points.size() - 1); for (int i = 0; i < points.size() - 1; i++) { double[] p0 = points.get(Math.max(0, i - 1)); double[] p1 = points.get(i); double[] p2 = points.get(i + 1); double[] p3 = points.get(Math.min(points.size() - 1, i + 2)); - for (int t = 1; t <= SMOOTH_SUBDIVISIONS; t++) { - double u = (double) t / SMOOTH_SUBDIVISIONS; - samples.add(new double[]{ - catmullRom(p0[0], p1[0], p2[0], p3[0], u), - catmullRom(p0[1], p1[1], p2[1], p3[1], u)}); + double[] c1 = {p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0}; + double[] c2 = {p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0}; + 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. + */ + private static ChartPrimitive bezierRun(String name, List run, DocumentStroke stroke) { + List controls = catmullRomControls(run); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (double[] p : run) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } + for (double[][] c : controls) { + for (double[] p : c) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); } } - return samples; + double w = Math.max(MIN_SEGMENT, maxX - minX); + double h = Math.max(MIN_SEGMENT, maxY - minY); + + List segments = new ArrayList<>(run.size()); + segments.add(DocumentPathSegment.moveTo( + (run.get(0)[0] - minX) / w, (run.get(0)[1] - minY) / h)); + for (int i = 0; i < controls.size(); i++) { + double[][] c = controls.get(i); + double[] end = run.get(i + 1); + segments.add(DocumentPathSegment.cubicTo( + (c[0][0] - minX) / w, (c[0][1] - minY) / h, + (c[1][0] - minX) / w, (c[1][1] - minY) / h, + (end[0] - minX) / w, (end[1] - minY) / h)); + } + PathNode node = new PathNode(name, w, h, segments, null, stroke, + DocumentInsets.zero(), DocumentInsets.zero()); + return new ChartPrimitive(node, minX, minY, w, h); } - private static double catmullRom(double p0, double p1, double p2, double p3, double t) { - double t2 = t * t; - double t3 = t2 * t; - return 0.5 * ((2 * p1) - + (-p0 + p2) * t - + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 - + (-p0 + 3 * p1 - 3 * p2 + p3) * t3); + /** + * 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}. + */ + private static void emitCurvedArea(List out, String name, + List run, double baselineY, + DocumentColor fill) { + List controls = catmullRomControls(run); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double minY = baselineY; + double maxY = baselineY; + for (double[] p : run) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } + for (double[][] c : controls) { + for (double[] p : c) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } + } + double w = Math.max(1.0, maxX - minX); + double h = Math.max(1.0, maxY - minY); + + List segments = new ArrayList<>(run.size() + 3); + segments.add(DocumentPathSegment.moveTo( + (run.get(0)[0] - minX) / w, (run.get(0)[1] - minY) / h)); + for (int i = 0; i < controls.size(); i++) { + double[][] c = controls.get(i); + double[] end = run.get(i + 1); + segments.add(DocumentPathSegment.cubicTo( + (c[0][0] - minX) / w, (c[0][1] - minY) / h, + (c[1][0] - minX) / w, (c[1][1] - minY) / h, + (end[0] - minX) / w, (end[1] - minY) / h)); + } + double baselineNorm = (baselineY - minY) / h; + segments.add(DocumentPathSegment.lineTo( + (run.get(run.size() - 1)[0] - minX) / w, baselineNorm)); + segments.add(DocumentPathSegment.lineTo( + (run.get(0)[0] - minX) / w, baselineNorm)); + segments.add(DocumentPathSegment.close()); + + PathNode node = new PathNode(name, w, h, segments, fill, null, + DocumentInsets.zero(), DocumentInsets.zero()); + out.add(new ChartPrimitive(node, minX, minY, w, h)); } /** diff --git a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java index 74b6a20f..e95d63f9 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -205,7 +205,9 @@ public PathBuilder margin(DocumentInsets margin) { } /** - * Builds the path node. + * Builds the path node. The built node copies the segment list, so the + * builder may keep accumulating segments afterwards — each {@code build()} + * snapshots the configuration at that moment. * * @return path node * @throws IllegalArgumentException if the segments do not start with a 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 d5a373d3..575efbfb 100644 --- a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java +++ b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java @@ -344,7 +344,7 @@ void areaFillsRenderUnderEveryStroke() { } @Test - void smoothLineSubdividesEachSpan() { + 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(); @@ -352,10 +352,71 @@ void smoothLineSubdividesEachSpan() { List out = ChartLayoutResolver.resolve( line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); - long segments = out.stream() - .filter(p -> p.node().name().startsWith("line_s0_seg")).count(); - // Two spans, eight sub-segments each. - assertThat(segments).isEqualTo(16); + // No tessellated sub-segments — the whole run is one native path. + 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(); + // MoveTo plus one cubic span per data gap. + 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.segments().get(2)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.CubicTo.class); + assertThat(curve.stroke()).isNotNull(); + assertThat(curve.fillColor()).isNull(); + } + + @Test + 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(); + + List out = ChartLayoutResolver.resolve( + line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); + + com.demcha.compose.document.node.PathNode area = + (com.demcha.compose.document.node.PathNode) byName(out, "area_s0_r0").node(); + // moveTo + 2 cubic spans + 2 baseline edges + close. + assertThat(area.segments()).hasSize(6); + assertThat(area.segments().get(3)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.LineTo.class); + assertThat(area.segments().get(5)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.Close.class); + assertThat(area.fillColor()).isNotNull(); + assertThat(area.fillColor().color().getAlpha()).isLessThan(255); + assertThat(area.stroke()).isNull(); + // The curved fill still paints under the curved stroke. + int areaIndex = -1; + int curveIndex = -1; + for (int i = 0; i < out.size(); i++) { + String name = out.get(i).node().name(); + if (name.equals("area_s0_r0")) { + areaIndex = i; + } + if (name.equals("line_s0_curve0")) { + curveIndex = i; + } + } + assertThat(areaIndex).isLessThan(curveIndex); + } + + @Test + 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(); + + List out = ChartLayoutResolver.resolve( + line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); + + // A two-point run has no curvature to express — it stays a segment. + byName(out, "line_s0_seg0"); + assertThat(out.stream().noneMatch(p -> p.node().name().startsWith("line_s0_curve"))) + .isTrue(); } @Test