diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bb2e9722..61ce1ba88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -105,8 +105,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 96e0e4d54..fb2c612af 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 672665057..02548556d 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/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))