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
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ Entries land here as they merge.
placement.** `ChartSpec.bar().horizontal(true)` transposes the chart
(categories on Y in reading order, value axis on X, labels at bar ends);
stacked bars label the category total. `ChartSpec.line().smooth(true)`
draws deterministic Catmull-Rom curves (fixed 8 sub-segments per span);
`.area(true)` fills each series down to the baseline with a translucent
series colour (`ChartStyle.areaOpacity`, default 0.35) — alpha-blended
fills layer legibly. `LegendPosition.TOP` and `RIGHT` now lay out as a top
draws deterministic Catmull-Rom curves as **native cubic Béziers** through
the vector path primitive — one `PathNode` per run, perfectly smooth at
any zoom level, zero tessellation; `.area(true)` fills each series down to
the baseline with a translucent series colour (`ChartStyle.areaOpacity`,
default 0.35) — alpha-blended fills layer legibly, and in smooth mode the
fill closes the exact stroke curve so fill and stroke edges coincide. `LegendPosition.TOP` and `RIGHT` now lay out as a top
strip / right column for every chart kind, including pie. The chart
resolver is split per kind (`BarChartLayout` / `LineChartLayout` /
`PieChartLayout` over a shared `ChartLayoutSupport`).
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 @@ -13,6 +13,7 @@
* operators — curves stay smooth at any zoom level.
*
* @author Artem Demchyshyn
* @since 1.8.0
*/
public final class PdfPathFragmentRenderHandler
implements PdfFragmentRenderHandler<PathFragmentPayload> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* Renders fixed polygon fragments (diamond, triangle, star, arbitrary rings).
*
* @author Artem Demchyshyn
* @since 1.8.0
*/
public final class PdfPolygonFragmentRenderHandler
implements PdfFragmentRenderHandler<PolygonFragmentPayload> {
Expand Down
196 changes: 148 additions & 48 deletions src/main/java/com/demcha/compose/document/chart/LineChartLayout.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.demcha.compose.document.chart;

import com.demcha.compose.document.node.EllipseNode;
import com.demcha.compose.document.node.PathNode;
import com.demcha.compose.document.node.PolygonNode;
import com.demcha.compose.document.style.*;

Expand All @@ -10,19 +11,20 @@
import static com.demcha.compose.document.chart.ChartLayoutSupport.*;

/**
* Geometry for line charts: straight or Catmull-Rom-smoothed polylines,
* optional translucent area fills, point markers, and collision-aware value
* labels.
* Geometry for line charts: straight polylines or native cubic-Bézier
* smoothed curves, optional translucent area fills (curved to match in
* smooth mode), point markers, and collision-aware value labels.
*
* <p>Smooth runs compile into a single {@code PathNode} per run whose
* Catmull-Rom-derived control points are pure arithmetic on the data points
* — the exact continuous curve the pre-1.8 fixed-step sampler approximated,
* now rendered with native PDF curve operators and zero tessellation.</p>
*
* @author Artem Demchyshyn
* @since 1.8.0
*/
final class LineChartLayout {

/**
* Sub-segments per Catmull-Rom span; fixed so geometry stays deterministic.
*/
private static final int SMOOTH_SUBDIVISIONS = 8;
private static final double DEFAULT_AREA_OPACITY = 0.35;

private LineChartLayout() {
Expand Down Expand Up @@ -52,43 +54,57 @@ static List<ChartPrimitive> resolve(ChartSpec.Line line, ChartStyle style,
double strokeWidth = style.lineWidth() == null ? DEFAULT_LINE_WIDTH : style.lineWidth();
double areaOpacity = style.areaOpacity() == null ? DEFAULT_AREA_OPACITY : style.areaOpacity();

// Per-series sampled polylines: contiguous non-null runs, optionally
// Catmull-Rom smoothed. Samples drive area fills and stroke segments;
// markers and labels stay on the original data points.
List<List<List<double[]>>> sampledRuns = new ArrayList<>();
// Per-series contiguous non-null runs of original data points. Smooth
// mode compiles each run into native Bézier primitives; markers and
// labels stay on the original data points either way.
List<List<List<double[]>>> seriesRuns = new ArrayList<>();
for (int s = 0; s < data.seriesCount(); s++) {
sampledRuns.add(sampleSeries(data.series().get(s), f, slotW, line.smooth()));
seriesRuns.add(sampleSeries(data.series().get(s), f, slotW));
}
boolean smooth = line.smooth();

// Pass 0 — area fills, under every stroke.
// Pass 0 — area fills, under every stroke. Smooth runs close the
// exact stroke curve down to the baseline so fill and stroke edges
// coincide.
if (line.area()) {
for (int s = 0; s < data.seriesCount(); s++) {
DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor();
DocumentColor fill = color.withOpacity(areaOpacity);
int runIndex = 0;
for (List<double[]> run : sampledRuns.get(s)) {
for (List<double[]> run : seriesRuns.get(s)) {
if (run.size() < 2) {
runIndex++;
continue;
}
emitAreaPolygon(out, "area_s" + s + "_r" + runIndex, run,
f.plotBottomY(), fill);
String name = "area_s" + s + "_r" + runIndex;
if (smooth && run.size() >= 3) {
emitCurvedArea(out, name, run, f.plotBottomY(), fill);
} else {
emitAreaPolygon(out, name, run, f.plotBottomY(), fill);
}
runIndex++;
}
}
}

// Pass 1 — every series' stroke segments.
// Pass 1 — series strokes: one native Bézier path per smooth run
// (three or more points), straight segments otherwise.
for (int s = 0; s < data.seriesCount(); s++) {
DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor();
DocumentStroke stroke = DocumentStroke.of(color, strokeWidth);
int n = 0;
for (List<double[]> run : sampledRuns.get(s)) {
for (int i = 1; i < run.size(); i++) {
out.add(segment("line_s" + s + "_seg" + n++,
run.get(i - 1)[0], run.get(i - 1)[1],
run.get(i)[0], run.get(i)[1], stroke));
int runIndex = 0;
for (List<double[]> run : seriesRuns.get(s)) {
if (smooth && run.size() >= 3) {
out.add(bezierRun("line_s" + s + "_curve" + runIndex, run, stroke));
} else {
for (int i = 1; i < run.size(); i++) {
out.add(segment("line_s" + s + "_seg" + n++,
run.get(i - 1)[0], run.get(i - 1)[1],
run.get(i)[0], run.get(i)[1], stroke));
}
}
runIndex++;
}
}

Expand Down Expand Up @@ -133,18 +149,18 @@ static List<ChartPrimitive> resolve(ChartSpec.Line line, ChartStyle style,
}

/**
* Splits a series into contiguous non-null runs of (x, y) samples.
* Splits a series into contiguous non-null runs of (x, y) data points.
*/
private static List<List<double[]>> sampleSeries(ChartData.Series series,
ChartLayoutSupport.Frame f,
double slotW, boolean smooth) {
double slotW) {
List<List<double[]>> runs = new ArrayList<>();
List<double[]> current = new ArrayList<>();
for (int c = 0; c < series.values().size(); c++) {
Double v = series.values().get(c);
if (v == null) {
if (!current.isEmpty()) {
runs.add(smooth ? smoothRun(current) : current);
runs.add(current);
current = new ArrayList<>();
}
continue;
Expand All @@ -153,44 +169,128 @@ private static List<List<double[]>> sampleSeries(ChartData.Series series,
f.plotLeftX() + (c + 0.5) * slotW, f.yForValue(v)});
}
if (!current.isEmpty()) {
runs.add(smooth ? smoothRun(current) : current);
runs.add(current);
}
return runs;
}

/**
* Subdivides a run with a centripetal-style Catmull-Rom spline (uniform
* parameterisation, clamped endpoints) into {@link #SMOOTH_SUBDIVISIONS}
* sub-segments per span. Pure arithmetic on the input points.
* Uniform Catmull-Rom control points (tension 0.5, clamped endpoints)
* for every span of a run: {@code c1 = p1 + (p2 - p0) / 6},
* {@code c2 = p2 - (p3 - p1) / 6}. Pure arithmetic on the data points —
* the exact continuous curve the pre-1.8 fixed-step sampler approximated.
* Returns one {@code [c1, c2]} pair per span.
*/
private static List<double[]> smoothRun(List<double[]> points) {
if (points.size() < 3) {
return points;
}
List<double[]> samples = new ArrayList<>();
samples.add(points.get(0));
private static List<double[][]> catmullRomControls(List<double[]> points) {
List<double[][]> controls = new ArrayList<>(points.size() - 1);
for (int i = 0; i < points.size() - 1; i++) {
double[] p0 = points.get(Math.max(0, i - 1));
double[] p1 = points.get(i);
double[] p2 = points.get(i + 1);
double[] p3 = points.get(Math.min(points.size() - 1, i + 2));
for (int t = 1; t <= SMOOTH_SUBDIVISIONS; t++) {
double u = (double) t / SMOOTH_SUBDIVISIONS;
samples.add(new double[]{
catmullRom(p0[0], p1[0], p2[0], p3[0], u),
catmullRom(p0[1], p1[1], p2[1], p3[1], u)});
double[] c1 = {p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0};
double[] c2 = {p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0};
controls.add(new double[][]{c1, c2});
}
return controls;
}

/**
* One stroked native-Bézier {@code PathNode} primitive covering a whole
* smooth run. The box is the bounding box of the data points and every
* control point, so normalized coordinates stay within the unit box by
* construction.
*/
private static ChartPrimitive bezierRun(String name, List<double[]> run, DocumentStroke stroke) {
List<double[][]> controls = catmullRomControls(run);
double minX = Double.POSITIVE_INFINITY;
double maxX = Double.NEGATIVE_INFINITY;
double minY = Double.POSITIVE_INFINITY;
double maxY = Double.NEGATIVE_INFINITY;
for (double[] p : run) {
minX = Math.min(minX, p[0]);
maxX = Math.max(maxX, p[0]);
minY = Math.min(minY, p[1]);
maxY = Math.max(maxY, p[1]);
}
for (double[][] c : controls) {
for (double[] p : c) {
minX = Math.min(minX, p[0]);
maxX = Math.max(maxX, p[0]);
minY = Math.min(minY, p[1]);
maxY = Math.max(maxY, p[1]);
}
}
return samples;
double w = Math.max(MIN_SEGMENT, maxX - minX);
double h = Math.max(MIN_SEGMENT, maxY - minY);

List<DocumentPathSegment> segments = new ArrayList<>(run.size());
segments.add(DocumentPathSegment.moveTo(
(run.get(0)[0] - minX) / w, (run.get(0)[1] - minY) / h));
for (int i = 0; i < controls.size(); i++) {
double[][] c = controls.get(i);
double[] end = run.get(i + 1);
segments.add(DocumentPathSegment.cubicTo(
(c[0][0] - minX) / w, (c[0][1] - minY) / h,
(c[1][0] - minX) / w, (c[1][1] - minY) / h,
(end[0] - minX) / w, (end[1] - minY) / h));
}
PathNode node = new PathNode(name, w, h, segments, null, stroke,
DocumentInsets.zero(), DocumentInsets.zero());
return new ChartPrimitive(node, minX, minY, w, h);
}

private static double catmullRom(double p0, double p1, double p2, double p3, double t) {
double t2 = t * t;
double t3 = t2 * t;
return 0.5 * ((2 * p1)
+ (-p0 + p2) * t
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
/**
* Curved area fill for a smooth run: the exact stroke curve closed down
* to the plot baseline with two straight edges, emitted as one filled
* {@code PathNode}.
*/
private static void emitCurvedArea(List<ChartPrimitive> out, String name,
List<double[]> run, double baselineY,
DocumentColor fill) {
List<double[][]> controls = catmullRomControls(run);
double minX = Double.POSITIVE_INFINITY;
double maxX = Double.NEGATIVE_INFINITY;
double minY = baselineY;
double maxY = baselineY;
for (double[] p : run) {
minX = Math.min(minX, p[0]);
maxX = Math.max(maxX, p[0]);
minY = Math.min(minY, p[1]);
maxY = Math.max(maxY, p[1]);
}
for (double[][] c : controls) {
for (double[] p : c) {
minX = Math.min(minX, p[0]);
maxX = Math.max(maxX, p[0]);
minY = Math.min(minY, p[1]);
maxY = Math.max(maxY, p[1]);
}
}
double w = Math.max(1.0, maxX - minX);
double h = Math.max(1.0, maxY - minY);

List<DocumentPathSegment> segments = new ArrayList<>(run.size() + 3);
segments.add(DocumentPathSegment.moveTo(
(run.get(0)[0] - minX) / w, (run.get(0)[1] - minY) / h));
for (int i = 0; i < controls.size(); i++) {
double[][] c = controls.get(i);
double[] end = run.get(i + 1);
segments.add(DocumentPathSegment.cubicTo(
(c[0][0] - minX) / w, (c[0][1] - minY) / h,
(c[1][0] - minX) / w, (c[1][1] - minY) / h,
(end[0] - minX) / w, (end[1] - minY) / h));
}
double baselineNorm = (baselineY - minY) / h;
segments.add(DocumentPathSegment.lineTo(
(run.get(run.size() - 1)[0] - minX) / w, baselineNorm));
segments.add(DocumentPathSegment.lineTo(
(run.get(0)[0] - minX) / w, baselineNorm));
segments.add(DocumentPathSegment.close());

PathNode node = new PathNode(name, w, h, segments, fill, null,
DocumentInsets.zero(), DocumentInsets.zero());
out.add(new ChartPrimitive(node, minX, minY, w, h));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ public PathBuilder margin(DocumentInsets margin) {
}

/**
* Builds the path node.
* Builds the path node. The built node copies the segment list, so the
* builder may keep accumulating segments afterwards — each {@code build()}
* snapshots the configuration at that moment.
*
* @return path node
* @throws IllegalArgumentException if the segments do not start with a
Expand Down
Loading