From 93bc9dc51d0fac5c349c47a480b4feca857a9dbb Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 11:10:16 +0100 Subject: [PATCH] fix(text): inline sparklines smooth with the chart Catmull-Rom curve SparklineGeometry densifies the value run with the same uniform Catmull-Rom spline the chart engine draws as native Beziers (12 sub-segments per span - facets stay under half a point at sparkline sizes), for both the area silhouette and the constant-thickness ribbon. Ribbon clamps the band CENTRE into [half, 1-half] before offsetting so spline overshoot at extremes cannot eat the thickness (pinned by the pair-distance test across all smoothed samples). API unchanged - every sparkline call site gets smooth runs automatically. Regenerated chart-showcase and feature-catalog previews. Full gate: 1261 tests, BUILD SUCCESS. --- CHANGELOG.md | 6 +- assets/readme/examples/chart-showcase.pdf | Bin 13688 -> 14687 bytes assets/readme/examples/feature-catalog.pdf | Bin 1813476 -> 1814526 bytes .../document/dsl/SparklineGeometry.java | 86 ++++++++++++++---- .../document/dsl/SparklineGeometryTest.java | 20 ++-- 5 files changed, 84 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d6260b9..f5822cfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,8 +70,10 @@ Entries land here as they merge. - **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color, values...)` draws a filled mini-area silhouette on the text baseline, and `sparklineLine(w, h, thickness, color, values...)` a constant-thickness line - band (full thickness preserved at the peaks). Both compile into the existing - inline-shape polygon run — a KPI trend next to a number, a skill trajectory + band (full thickness preserved at the peaks). Both runs are smoothed with + the same Catmull-Rom curve the chart engine uses (densified to 12 + sub-segments per span — facets stay under half a point at sparkline + sizes), and both compile into the existing inline-shape polygon run — a KPI trend next to a number, a skill trajectory inside a CV line. - **Configurable line-chart point markers.** `PointMarker` draws an ellipse at every data point — independent width/height axes, explicit fill (or the diff --git a/assets/readme/examples/chart-showcase.pdf b/assets/readme/examples/chart-showcase.pdf index 96e0e4d54f3dc5880b358410997cad38694a9832..fb2c612afe0596cfb80cd460f8bf8b8d6ed51edf 100644 GIT binary patch delta 3977 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 delta 2962 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 OIcWe1FE2z-gXBLe?>F`U delta 3796 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 DVUqkD diff --git a/src/main/java/com/demcha/compose/document/dsl/SparklineGeometry.java b/src/main/java/com/demcha/compose/document/dsl/SparklineGeometry.java index a5490475c..d01b7064c 100644 --- a/src/main/java/com/demcha/compose/document/dsl/SparklineGeometry.java +++ b/src/main/java/com/demcha/compose/document/dsl/SparklineGeometry.java @@ -12,28 +12,39 @@ * arithmetic only, unit-tested in isolation. * *

Values map linearly: the run's minimum sits on {@code y = 0}, its maximum - * on {@code y = 1}; a flat run centres on {@code y = 0.5}. Points are evenly - * spaced across {@code x = 0..1}.

+ * on {@code y = 1}; a flat run centres on {@code y = 0.5}. Data points are + * evenly spaced across {@code x = 0..1}, and the polyline between them is + * smoothed with the same uniform Catmull-Rom curve the chart engine uses, + * densified to {@value #SMOOTH_SUBDIVISIONS} sub-segments per span — at + * sparkline sizes the facets are far below visual resolution, so the run + * reads as a true curve while staying a deterministic polygon ring.

* * @author Artem Demchyshyn * @since 1.8.0 */ final class SparklineGeometry { + /** + * Sub-segments per data span. Inline shapes stay polygon rings, so the + * curve is densified instead of emitted as Béziers; 12 segments on a + * ~40 pt sparkline puts every facet under half a point. + */ + private static final int SMOOTH_SUBDIVISIONS = 12; + private SparklineGeometry() { } /** - * Area silhouette: the value polyline closed down to the baseline. + * Area silhouette: the smoothed value curve closed down to the baseline. * * @param values at least two finite values - * @return closed ring of {@code n + 2} normalized vertices + * @return closed ring of smoothed normalized vertices */ static List areaPoints(double[] values) { - double[] ys = normalize(values); - List points = new ArrayList<>(ys.length + 2); - for (int i = 0; i < ys.length; i++) { - points.add(new ShapePoint(x(i, ys.length), ys[i])); + double[][] curve = smoothCurve(normalize(values)); + List points = new ArrayList<>(curve.length + 2); + for (double[] p : curve) { + points.add(new ShapePoint(p[0], p[1])); } points.add(new ShapePoint(1.0, 0.0)); points.add(new ShapePoint(0.0, 0.0)); @@ -48,7 +59,8 @@ static List areaPoints(double[] values) { * * @param values at least two finite values * @param thicknessFraction band thickness as a fraction of the box height, in (0, 1) - * @return closed ring of {@code 2n} normalized vertices + * @return closed ring of smoothed normalized vertices (top edge forward, + * bottom edge back) */ static List ribbonPoints(double[] values, double thicknessFraction) { if (thicknessFraction <= 0 || thicknessFraction >= 1 || Double.isNaN(thicknessFraction)) { @@ -62,16 +74,58 @@ static List ribbonPoints(double[] values, double thicknessFraction) for (int i = 0; i < ys.length; i++) { ys[i] = half + ys[i] * (1.0 - thicknessFraction); } - List points = new ArrayList<>(ys.length * 2); - for (int i = 0; i < ys.length; i++) { - points.add(new ShapePoint(x(i, ys.length), ys[i] + half)); + double[][] curve = smoothCurve(ys); + // Clamp the band CENTRE into [half, 1 - half] (spline overshoot may + // poke past the compressed range) so the ±half offsets stay inside + // the unit box without eating into the band thickness. + List points = new ArrayList<>(curve.length * 2); + for (double[] p : curve) { + double centre = Math.max(half, Math.min(1.0 - half, p[1])); + points.add(new ShapePoint(p[0], centre + half)); } - for (int i = ys.length - 1; i >= 0; i--) { - points.add(new ShapePoint(x(i, ys.length), ys[i] - half)); + for (int i = curve.length - 1; i >= 0; i--) { + double centre = Math.max(half, Math.min(1.0 - half, curve[i][1])); + points.add(new ShapePoint(curve[i][0], centre - half)); } return points; } + /** + * Densifies the evenly-spaced value run with a uniform Catmull-Rom curve + * (tension 0.5, clamped endpoints) — the same spline the chart engine + * draws as native Béziers. Returns {@code (x, y)} samples including every + * original point; y is clamped to the unit box because the spline may + * overshoot slightly around extremes. + */ + private static double[][] smoothCurve(double[] ys) { + int spans = ys.length - 1; + double[][] out = new double[spans * SMOOTH_SUBDIVISIONS + 1][2]; + out[0] = new double[]{0.0, clamp01(ys[0])}; + int n = 1; + for (int i = 0; i < spans; i++) { + double p0 = ys[Math.max(0, i - 1)]; + double p1 = ys[i]; + double p2 = ys[i + 1]; + double p3 = ys[Math.min(ys.length - 1, i + 2)]; + for (int s = 1; s <= SMOOTH_SUBDIVISIONS; s++) { + double t = (double) s / SMOOTH_SUBDIVISIONS; + double t2 = t * t; + double t3 = t2 * t; + double y = 0.5 * ((2 * p1) + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3); + double x = (i + t) / spans; + out[n++] = new double[]{x, clamp01(y)}; + } + } + return out; + } + + private static double clamp01(double v) { + return Math.max(0.0, Math.min(1.0, v)); + } + private static double[] normalize(double[] values) { if (values == null || values.length < 2) { throw new IllegalArgumentException("sparkline needs at least two values"); @@ -95,8 +149,4 @@ private static double[] normalize(double[] values) { } return ys; } - - private static double x(int index, int count) { - return (double) index / (count - 1); - } } diff --git a/src/test/java/com/demcha/compose/document/dsl/SparklineGeometryTest.java b/src/test/java/com/demcha/compose/document/dsl/SparklineGeometryTest.java index d52da5e31..f47eeb7e5 100644 --- a/src/test/java/com/demcha/compose/document/dsl/SparklineGeometryTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/SparklineGeometryTest.java @@ -23,12 +23,14 @@ class SparklineGeometryTest { void areaRingClosesToTheBaselineWithNormalizedExtremes() { List pts = SparklineGeometry.areaPoints(new double[] {2.0, 8.0, 5.0}); - assertThat(pts).hasSize(5); // 3 values + 2 baseline corners + // 2 spans x 12 smooth sub-segments + start point + 2 baseline corners. + assertThat(pts).hasSize(2 * 12 + 1 + 2); assertThat(pts.get(0).y()).isCloseTo(0.0, within(1e-12)); // min -> bottom - assertThat(pts.get(1).y()).isCloseTo(1.0, within(1e-12)); // max -> top - assertThat(pts.get(1).x()).isCloseTo(0.5, within(1e-12)); // evenly spaced - assertThat(pts.get(3)).isEqualTo(new ShapePoint(1.0, 0.0)); - assertThat(pts.get(4)).isEqualTo(new ShapePoint(0.0, 0.0)); + // The original data points survive at the span boundaries. + assertThat(pts.get(12).y()).isCloseTo(1.0, within(1e-12)); // max -> top + assertThat(pts.get(12).x()).isCloseTo(0.5, within(1e-12)); // evenly spaced + assertThat(pts.get(pts.size() - 2)).isEqualTo(new ShapePoint(1.0, 0.0)); + assertThat(pts.get(pts.size() - 1)).isEqualTo(new ShapePoint(0.0, 0.0)); } @Test @@ -38,9 +40,10 @@ void flatRunCentresAndRibbonKeepsConstantThickness() { List ribbon = SparklineGeometry.ribbonPoints( new double[] {1.0, 3.0, 2.0}, 0.2); - assertThat(ribbon).hasSize(6); // 2n vertices + int curve = 2 * 12 + 1; // smoothed samples per edge + assertThat(ribbon).hasSize(curve * 2); // Top edge runs forward, bottom edge runs back: pair i with (2n-1-i). - for (int i = 0; i < 3; i++) { + for (int i = 0; i < curve; i++) { ShapePoint top = ribbon.get(i); ShapePoint bottom = ribbon.get(ribbon.size() - 1 - i); assertThat(top.x()).isCloseTo(bottom.x(), within(1e-12)); @@ -72,7 +75,8 @@ void richTextSparklineBecomesAPolygonInlineRun() { ShapeOutline.Polygon polygon = (ShapeOutline.Polygon) run.layers().get(0).outline(); assertThat(polygon.width()).isEqualTo(36.0); assertThat(polygon.height()).isEqualTo(9.0); - assertThat(polygon.points()).hasSize(7); // 5 values + 2 baseline corners + // 4 spans x 12 sub-segments + start + 2 baseline corners. + assertThat(polygon.points()).hasSize(4 * 12 + 1 + 2); assertThatThrownBy(() -> RichText.text("x") .sparklineLine(36, 9, 12, DocumentColor.ROYAL_BLUE, 1, 2))