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))