Skip to content

Commit 93bc9dc

Browse files
committed
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.
1 parent 41dae5c commit 93bc9dc

5 files changed

Lines changed: 84 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ Entries land here as they merge.
7070
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
7171
values...)` draws a filled mini-area silhouette on the text baseline, and
7272
`sparklineLine(w, h, thickness, color, values...)` a constant-thickness line
73-
band (full thickness preserved at the peaks). Both compile into the existing
74-
inline-shape polygon run — a KPI trend next to a number, a skill trajectory
73+
band (full thickness preserved at the peaks). Both runs are smoothed with
74+
the same Catmull-Rom curve the chart engine uses (densified to 12
75+
sub-segments per span — facets stay under half a point at sparkline
76+
sizes), and both compile into the existing inline-shape polygon run — a KPI trend next to a number, a skill trajectory
7577
inside a CV line.
7678
- **Configurable line-chart point markers.** `PointMarker` draws an ellipse at
7779
every data point — independent width/height axes, explicit fill (or the
999 Bytes
Binary file not shown.
1.03 KB
Binary file not shown.

src/main/java/com/demcha/compose/document/dsl/SparklineGeometry.java

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,39 @@
1212
* arithmetic only, unit-tested in isolation.
1313
*
1414
* <p>Values map linearly: the run's minimum sits on {@code y = 0}, its maximum
15-
* on {@code y = 1}; a flat run centres on {@code y = 0.5}. Points are evenly
16-
* spaced across {@code x = 0..1}.</p>
15+
* on {@code y = 1}; a flat run centres on {@code y = 0.5}. Data points are
16+
* evenly spaced across {@code x = 0..1}, and the polyline between them is
17+
* smoothed with the same uniform Catmull-Rom curve the chart engine uses,
18+
* densified to {@value #SMOOTH_SUBDIVISIONS} sub-segments per span — at
19+
* sparkline sizes the facets are far below visual resolution, so the run
20+
* reads as a true curve while staying a deterministic polygon ring.</p>
1721
*
1822
* @author Artem Demchyshyn
1923
* @since 1.8.0
2024
*/
2125
final class SparklineGeometry {
2226

27+
/**
28+
* Sub-segments per data span. Inline shapes stay polygon rings, so the
29+
* curve is densified instead of emitted as Béziers; 12 segments on a
30+
* ~40 pt sparkline puts every facet under half a point.
31+
*/
32+
private static final int SMOOTH_SUBDIVISIONS = 12;
33+
2334
private SparklineGeometry() {
2435
}
2536

2637
/**
27-
* Area silhouette: the value polyline closed down to the baseline.
38+
* Area silhouette: the smoothed value curve closed down to the baseline.
2839
*
2940
* @param values at least two finite values
30-
* @return closed ring of {@code n + 2} normalized vertices
41+
* @return closed ring of smoothed normalized vertices
3142
*/
3243
static List<ShapePoint> areaPoints(double[] values) {
33-
double[] ys = normalize(values);
34-
List<ShapePoint> points = new ArrayList<>(ys.length + 2);
35-
for (int i = 0; i < ys.length; i++) {
36-
points.add(new ShapePoint(x(i, ys.length), ys[i]));
44+
double[][] curve = smoothCurve(normalize(values));
45+
List<ShapePoint> points = new ArrayList<>(curve.length + 2);
46+
for (double[] p : curve) {
47+
points.add(new ShapePoint(p[0], p[1]));
3748
}
3849
points.add(new ShapePoint(1.0, 0.0));
3950
points.add(new ShapePoint(0.0, 0.0));
@@ -48,7 +59,8 @@ static List<ShapePoint> areaPoints(double[] values) {
4859
*
4960
* @param values at least two finite values
5061
* @param thicknessFraction band thickness as a fraction of the box height, in (0, 1)
51-
* @return closed ring of {@code 2n} normalized vertices
62+
* @return closed ring of smoothed normalized vertices (top edge forward,
63+
* bottom edge back)
5264
*/
5365
static List<ShapePoint> ribbonPoints(double[] values, double thicknessFraction) {
5466
if (thicknessFraction <= 0 || thicknessFraction >= 1 || Double.isNaN(thicknessFraction)) {
@@ -62,16 +74,58 @@ static List<ShapePoint> ribbonPoints(double[] values, double thicknessFraction)
6274
for (int i = 0; i < ys.length; i++) {
6375
ys[i] = half + ys[i] * (1.0 - thicknessFraction);
6476
}
65-
List<ShapePoint> points = new ArrayList<>(ys.length * 2);
66-
for (int i = 0; i < ys.length; i++) {
67-
points.add(new ShapePoint(x(i, ys.length), ys[i] + half));
77+
double[][] curve = smoothCurve(ys);
78+
// Clamp the band CENTRE into [half, 1 - half] (spline overshoot may
79+
// poke past the compressed range) so the ±half offsets stay inside
80+
// the unit box without eating into the band thickness.
81+
List<ShapePoint> points = new ArrayList<>(curve.length * 2);
82+
for (double[] p : curve) {
83+
double centre = Math.max(half, Math.min(1.0 - half, p[1]));
84+
points.add(new ShapePoint(p[0], centre + half));
6885
}
69-
for (int i = ys.length - 1; i >= 0; i--) {
70-
points.add(new ShapePoint(x(i, ys.length), ys[i] - half));
86+
for (int i = curve.length - 1; i >= 0; i--) {
87+
double centre = Math.max(half, Math.min(1.0 - half, curve[i][1]));
88+
points.add(new ShapePoint(curve[i][0], centre - half));
7189
}
7290
return points;
7391
}
7492

93+
/**
94+
* Densifies the evenly-spaced value run with a uniform Catmull-Rom curve
95+
* (tension 0.5, clamped endpoints) — the same spline the chart engine
96+
* draws as native Béziers. Returns {@code (x, y)} samples including every
97+
* original point; y is clamped to the unit box because the spline may
98+
* overshoot slightly around extremes.
99+
*/
100+
private static double[][] smoothCurve(double[] ys) {
101+
int spans = ys.length - 1;
102+
double[][] out = new double[spans * SMOOTH_SUBDIVISIONS + 1][2];
103+
out[0] = new double[]{0.0, clamp01(ys[0])};
104+
int n = 1;
105+
for (int i = 0; i < spans; i++) {
106+
double p0 = ys[Math.max(0, i - 1)];
107+
double p1 = ys[i];
108+
double p2 = ys[i + 1];
109+
double p3 = ys[Math.min(ys.length - 1, i + 2)];
110+
for (int s = 1; s <= SMOOTH_SUBDIVISIONS; s++) {
111+
double t = (double) s / SMOOTH_SUBDIVISIONS;
112+
double t2 = t * t;
113+
double t3 = t2 * t;
114+
double y = 0.5 * ((2 * p1)
115+
+ (-p0 + p2) * t
116+
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2
117+
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
118+
double x = (i + t) / spans;
119+
out[n++] = new double[]{x, clamp01(y)};
120+
}
121+
}
122+
return out;
123+
}
124+
125+
private static double clamp01(double v) {
126+
return Math.max(0.0, Math.min(1.0, v));
127+
}
128+
75129
private static double[] normalize(double[] values) {
76130
if (values == null || values.length < 2) {
77131
throw new IllegalArgumentException("sparkline needs at least two values");
@@ -95,8 +149,4 @@ private static double[] normalize(double[] values) {
95149
}
96150
return ys;
97151
}
98-
99-
private static double x(int index, int count) {
100-
return (double) index / (count - 1);
101-
}
102152
}

src/test/java/com/demcha/compose/document/dsl/SparklineGeometryTest.java

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ class SparklineGeometryTest {
2323
void areaRingClosesToTheBaselineWithNormalizedExtremes() {
2424
List<ShapePoint> pts = SparklineGeometry.areaPoints(new double[] {2.0, 8.0, 5.0});
2525

26-
assertThat(pts).hasSize(5); // 3 values + 2 baseline corners
26+
// 2 spans x 12 smooth sub-segments + start point + 2 baseline corners.
27+
assertThat(pts).hasSize(2 * 12 + 1 + 2);
2728
assertThat(pts.get(0).y()).isCloseTo(0.0, within(1e-12)); // min -> bottom
28-
assertThat(pts.get(1).y()).isCloseTo(1.0, within(1e-12)); // max -> top
29-
assertThat(pts.get(1).x()).isCloseTo(0.5, within(1e-12)); // evenly spaced
30-
assertThat(pts.get(3)).isEqualTo(new ShapePoint(1.0, 0.0));
31-
assertThat(pts.get(4)).isEqualTo(new ShapePoint(0.0, 0.0));
29+
// The original data points survive at the span boundaries.
30+
assertThat(pts.get(12).y()).isCloseTo(1.0, within(1e-12)); // max -> top
31+
assertThat(pts.get(12).x()).isCloseTo(0.5, within(1e-12)); // evenly spaced
32+
assertThat(pts.get(pts.size() - 2)).isEqualTo(new ShapePoint(1.0, 0.0));
33+
assertThat(pts.get(pts.size() - 1)).isEqualTo(new ShapePoint(0.0, 0.0));
3234
}
3335

3436
@Test
@@ -38,9 +40,10 @@ void flatRunCentresAndRibbonKeepsConstantThickness() {
3840

3941
List<ShapePoint> ribbon = SparklineGeometry.ribbonPoints(
4042
new double[] {1.0, 3.0, 2.0}, 0.2);
41-
assertThat(ribbon).hasSize(6); // 2n vertices
43+
int curve = 2 * 12 + 1; // smoothed samples per edge
44+
assertThat(ribbon).hasSize(curve * 2);
4245
// Top edge runs forward, bottom edge runs back: pair i with (2n-1-i).
43-
for (int i = 0; i < 3; i++) {
46+
for (int i = 0; i < curve; i++) {
4447
ShapePoint top = ribbon.get(i);
4548
ShapePoint bottom = ribbon.get(ribbon.size() - 1 - i);
4649
assertThat(top.x()).isCloseTo(bottom.x(), within(1e-12));
@@ -72,7 +75,8 @@ void richTextSparklineBecomesAPolygonInlineRun() {
7275
ShapeOutline.Polygon polygon = (ShapeOutline.Polygon) run.layers().get(0).outline();
7376
assertThat(polygon.width()).isEqualTo(36.0);
7477
assertThat(polygon.height()).isEqualTo(9.0);
75-
assertThat(polygon.points()).hasSize(7); // 5 values + 2 baseline corners
78+
// 4 spans x 12 sub-segments + start + 2 baseline corners.
79+
assertThat(polygon.points()).hasSize(4 * 12 + 1 + 2);
7680

7781
assertThatThrownBy(() -> RichText.text("x")
7882
.sparklineLine(36, 9, 12, DocumentColor.ROYAL_BLUE, 1, 2))

0 commit comments

Comments
 (0)