buildMonotonePath method
Builds a smooth path using monotone cubic interpolation (Fritsch–Carlson). Unlike a cardinal spline, this never overshoots the data: the curve introduces no false peaks, no dips below a local minimum, and stays within the value range between points — so a smoothed line remains a faithful reading of the data.
Works for x ordered strictly ascending or descending; because the interpolant is reversal-symmetric, feeding it a reversed point list yields the exact mirror curve — which keeps stacked-area band boundaries aligned. Falls back to the cardinal spline when x is not strictly monotonic.
Implementation
Path buildMonotonePath(List<Offset> points, {bool close = false}) {
final n = points.length;
if (n < 3) {
final path = Path();
if (n == 0) return path;
path.moveTo(points.first.dx, points.first.dy);
for (var i = 1; i < n; i++) {
path.lineTo(points[i].dx, points[i].dy);
}
if (close) path.close();
return path;
}
// x-deltas between consecutive points.
final dx = List<double>.filled(n - 1, 0);
for (var i = 0; i < n - 1; i++) {
dx[i] = points[i + 1].dx - points[i].dx;
}
// Require strictly monotonic x (all deltas the same, non-zero sign);
// otherwise x isn't a function of position — defer to the cardinal spline.
final sign = dx[0].sign;
if (sign == 0 || dx.any((d) => d.sign != sign)) {
return buildSmoothPath(points, close: close);
}
// Secant slopes between consecutive points.
final slope = List<double>.filled(n - 1, 0);
for (var i = 0; i < n - 1; i++) {
slope[i] = (points[i + 1].dy - points[i].dy) / dx[i];
}
// Tangents: average of neighbouring secants, clamped to 0 at extrema so
// the curve can't overshoot a peak or trough.
final m = List<double>.filled(n, 0);
m[0] = slope[0];
m[n - 1] = slope[n - 2];
for (var i = 1; i < n - 1; i++) {
m[i] = (slope[i - 1] * slope[i] <= 0)
? 0.0
: (slope[i - 1] + slope[i]) / 2;
}
// Fritsch–Carlson: rein in tangents that would break monotonicity.
for (var i = 0; i < n - 1; i++) {
if (slope[i] == 0) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
final a = m[i] / slope[i];
final b = m[i + 1] / slope[i];
final h = a * a + b * b;
if (h > 9) {
final t = 3 / math.sqrt(h);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
// Emit Hermite segments as cubic Béziers (control points at thirds).
final path = Path()..moveTo(points.first.dx, points.first.dy);
for (var i = 0; i < n - 1; i++) {
final p1 = points[i];
final p2 = points[i + 1];
final third = dx[i] / 3;
path.cubicTo(
p1.dx + third,
p1.dy + m[i] * third,
p2.dx - third,
p2.dy - m[i + 1] * third,
p2.dx,
p2.dy,
);
}
if (close) path.close();
return path;
}