Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified assets/readme/examples/chart-showcase.pdf
Binary file not shown.
Binary file modified assets/readme/examples/feature-catalog.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,39 @@
* arithmetic only, unit-tested in isolation.
*
* <p>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}.</p>
* 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.</p>
*
* @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<ShapePoint> areaPoints(double[] values) {
double[] ys = normalize(values);
List<ShapePoint> 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<ShapePoint> 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));
Expand All @@ -48,7 +59,8 @@ static List<ShapePoint> 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<ShapePoint> ribbonPoints(double[] values, double thicknessFraction) {
if (thicknessFraction <= 0 || thicknessFraction >= 1 || Double.isNaN(thicknessFraction)) {
Expand All @@ -62,16 +74,58 @@ static List<ShapePoint> ribbonPoints(double[] values, double thicknessFraction)
for (int i = 0; i < ys.length; i++) {
ys[i] = half + ys[i] * (1.0 - thicknessFraction);
}
List<ShapePoint> 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<ShapePoint> 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");
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ class SparklineGeometryTest {
void areaRingClosesToTheBaselineWithNormalizedExtremes() {
List<ShapePoint> 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
Expand All @@ -38,9 +40,10 @@ void flatRunCentresAndRibbonKeepsConstantThickness() {

List<ShapePoint> 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));
Expand Down Expand Up @@ -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))
Expand Down