diff --git a/CHANGELOG.md b/CHANGELOG.md index e20f3c45f..08251745a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ -## newVersion +## 1.2.0 * **BUGFIX** (by @imaNNeo) Consider the `enabled` property in [LineTouchData](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/line_chart.md#linetouchdata-read-about-touch-handling), [BarTouchData](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/bar_chart.md#bartouchdata-read-about-touch-handling), [PieTouchData](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/pie_chart.md#pietouchdata-read-about-touch-handling), [ScatterTouchData](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/scatter_chart.md#scattertouchdata-read-about-touch-handling), [RadarTouchData](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/radar_chart.md#radartouchdata-read-about-touch-handling) and [CandlestickTouchData](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/candlestick_chart.md#candlesticktouchdata-read-about-touch-handling), #1676 +* **BREAKING** ⚠️ (by @huanghui1998hhh) Enhanced line chart curve function with a new extensible curve system. Introduced `LineChartCurve` abstract class. You can implement your own curve or use built-in curve: `LineChartCubicTensionCurve`(old curve implementation), and `LineChartCubicMonotoneCurve`. +**Migration Guide:** + +Old API (deprecated): +```dart +LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.35, + preventCurveOverShooting: true, + preventCurveOvershootingThreshold: 10.0, +) +``` + +New API: +```dart +LineChartBarData( + spots: spots, + curve: LineChartCurve.cubicTension( // or use LineChartCubicTensionCurve() + smoothness: 0.35, + preventCurveOverShooting: true, + preventCurveOvershootingThreshold: 10.0, + ), +) +``` + +Or use the new monotone curve: +```dart +LineChartBarData( + spots: spots, + curve: LineChartCurve.cubicMonotone( // or use LineChartCubicMonotoneCurve() + smooth: 0.5, + monotone: SmoothMonotone.x, // Prevents overshooting along X-axis + ), +) +``` + +For straight lines (isCurved: false): +```dart +LineChartBarData( + spots: spots, + curve: LineChartCurve.noCurve, // or omit the curve parameter (default) +) +``` + +Check the updated [LineChart documentation](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/line_chart.md#linechartcurve) for more details. ## 1.1.1 * **IMPROVEMENT** (by @imaNNeo) Upgrade `vector_math` dependency to `2.2.0`, #1985 diff --git a/example/lib/presentation/samples/bar/bar_chart_sample7.dart b/example/lib/presentation/samples/bar/bar_chart_sample7.dart index ee2f953a1..664e47477 100644 --- a/example/lib/presentation/samples/bar/bar_chart_sample7.dart +++ b/example/lib/presentation/samples/bar/bar_chart_sample7.dart @@ -230,7 +230,8 @@ class _IconWidgetState extends AnimatedWidgetBaseState<_IconWidget> { final rotation = math.pi * 4 * _rotationTween!.evaluate(animation); final scale = 1 + _rotationTween!.evaluate(animation) * 0.5; return Transform( - transform: Matrix4.rotationZ(rotation).scaledByDouble(scale, scale, scale, 1.0), + transform: + Matrix4.rotationZ(rotation).scaledByDouble(scale, scale, scale, 1.0), origin: const Offset(14, 14), child: Icon( widget.isSelected ? Icons.face_retouching_natural : Icons.face, diff --git a/example/lib/presentation/samples/line/line_chart_sample1.dart b/example/lib/presentation/samples/line/line_chart_sample1.dart index 4b8bf62c3..cd4cb3ad4 100644 --- a/example/lib/presentation/samples/line/line_chart_sample1.dart +++ b/example/lib/presentation/samples/line/line_chart_sample1.dart @@ -163,7 +163,7 @@ class _LineChart extends StatelessWidget { ); LineChartBarData get lineChartBarData1_1 => LineChartBarData( - isCurved: true, + curve: const LineChartCubicTensionCurve(), color: AppColors.contentColorGreen, barWidth: 8, isStrokeCapRound: true, @@ -181,7 +181,7 @@ class _LineChart extends StatelessWidget { ); LineChartBarData get lineChartBarData1_2 => LineChartBarData( - isCurved: true, + curve: const LineChartCubicTensionCurve(), color: AppColors.contentColorPink, barWidth: 8, isStrokeCapRound: true, @@ -201,7 +201,7 @@ class _LineChart extends StatelessWidget { ); LineChartBarData get lineChartBarData1_3 => LineChartBarData( - isCurved: true, + curve: const LineChartCubicMonotoneCurve(), color: AppColors.contentColorCyan, barWidth: 8, isStrokeCapRound: true, @@ -217,8 +217,7 @@ class _LineChart extends StatelessWidget { ); LineChartBarData get lineChartBarData2_1 => LineChartBarData( - isCurved: true, - curveSmoothness: 0, + curve: LineChartCurve.noCurve, color: AppColors.contentColorGreen.withValues(alpha: 0.5), barWidth: 4, isStrokeCapRound: true, @@ -236,7 +235,7 @@ class _LineChart extends StatelessWidget { ); LineChartBarData get lineChartBarData2_2 => LineChartBarData( - isCurved: true, + curve: const LineChartCubicTensionCurve(), color: AppColors.contentColorPink.withValues(alpha: 0.5), barWidth: 4, isStrokeCapRound: true, @@ -256,8 +255,7 @@ class _LineChart extends StatelessWidget { ); LineChartBarData get lineChartBarData2_3 => LineChartBarData( - isCurved: true, - curveSmoothness: 0, + curve: LineChartCurve.noCurve, color: AppColors.contentColorCyan.withValues(alpha: 0.5), barWidth: 2, isStrokeCapRound: true, diff --git a/example/lib/presentation/samples/line/line_chart_sample10.dart b/example/lib/presentation/samples/line/line_chart_sample10.dart index 5b1a28eb3..f216b07c5 100644 --- a/example/lib/presentation/samples/line/line_chart_sample10.dart +++ b/example/lib/presentation/samples/line/line_chart_sample10.dart @@ -119,7 +119,7 @@ class _LineChartSample10State extends State { stops: const [0.1, 1.0], ), barWidth: 4, - isCurved: false, + curve: LineChartCurve.noCurve, ); } @@ -134,7 +134,7 @@ class _LineChartSample10State extends State { stops: const [0.1, 1.0], ), barWidth: 4, - isCurved: false, + curve: LineChartCurve.noCurve, ); } diff --git a/example/lib/presentation/samples/line/line_chart_sample13.dart b/example/lib/presentation/samples/line/line_chart_sample13.dart index d5ef4dc21..f2a8ab28e 100644 --- a/example/lib/presentation/samples/line/line_chart_sample13.dart +++ b/example/lib/presentation/samples/line/line_chart_sample13.dart @@ -172,7 +172,7 @@ class _LineChartSample13State extends State { ), ); }).toList(), - isCurved: false, + curve: LineChartCurve.noCurve, dotData: const FlDotData(show: false), color: AppColors.contentColorBlue, barWidth: 1, diff --git a/example/lib/presentation/samples/line/line_chart_sample2.dart b/example/lib/presentation/samples/line/line_chart_sample2.dart index 7535242e8..3b12fc6b8 100644 --- a/example/lib/presentation/samples/line/line_chart_sample2.dart +++ b/example/lib/presentation/samples/line/line_chart_sample2.dart @@ -155,7 +155,7 @@ class _LineChartSample2State extends State { FlSpot(9.5, 3), FlSpot(11, 4), ], - isCurved: true, + curve: const LineChartCubicTensionCurve(), gradient: LinearGradient( colors: gradientColors, ), @@ -242,7 +242,7 @@ class _LineChartSample2State extends State { FlSpot(9.5, 3.44), FlSpot(11, 3.44), ], - isCurved: true, + curve: const LineChartCubicTensionCurve(), gradient: LinearGradient( colors: [ ColorTween(begin: gradientColors[0], end: gradientColors[1]) diff --git a/example/lib/presentation/samples/line/line_chart_sample3.dart b/example/lib/presentation/samples/line/line_chart_sample3.dart index f0e517316..3a86d99d3 100644 --- a/example/lib/presentation/samples/line/line_chart_sample3.dart +++ b/example/lib/presentation/samples/line/line_chart_sample3.dart @@ -283,7 +283,7 @@ class _LineChartSample3State extends State { spots: widget.yValues.asMap().entries.map((e) { return FlSpot(e.key.toDouble(), e.value); }).toList(), - isCurved: false, + curve: LineChartCurve.noCurve, barWidth: 4, color: widget.lineColor, belowBarData: BarAreaData( diff --git a/example/lib/presentation/samples/line/line_chart_sample4.dart b/example/lib/presentation/samples/line/line_chart_sample4.dart index 893f5e152..3c3b4ebe6 100644 --- a/example/lib/presentation/samples/line/line_chart_sample4.dart +++ b/example/lib/presentation/samples/line/line_chart_sample4.dart @@ -93,7 +93,7 @@ class LineChartSample4 extends StatelessWidget { FlSpot(10, 6), FlSpot(11, 7), ], - isCurved: true, + curve: const LineChartCubicTensionCurve(), barWidth: 8, color: mainLineColor, belowBarData: BarAreaData( diff --git a/example/lib/presentation/samples/line/line_chart_sample5.dart b/example/lib/presentation/samples/line/line_chart_sample5.dart index d6a78d4a4..66d13d3ec 100644 --- a/example/lib/presentation/samples/line/line_chart_sample5.dart +++ b/example/lib/presentation/samples/line/line_chart_sample5.dart @@ -68,7 +68,7 @@ class _LineChartSample5State extends State { LineChartBarData( showingIndicators: showingTooltipOnSpots, spots: allSpots, - isCurved: true, + curve: const LineChartCubicTensionCurve(), barWidth: 4, shadow: const Shadow( blurRadius: 8, diff --git a/example/lib/presentation/samples/line/line_chart_sample6.dart b/example/lib/presentation/samples/line/line_chart_sample6.dart index 9f2f306ea..c9fd219e9 100644 --- a/example/lib/presentation/samples/line/line_chart_sample6.dart +++ b/example/lib/presentation/samples/line/line_chart_sample6.dart @@ -165,7 +165,7 @@ class LineChartSample6 extends StatelessWidget { ], ), spots: reverseSpots(spots, minSpotY, maxSpotY), - isCurved: true, + curve: const LineChartCubicTensionCurve(), isStrokeCapRound: true, barWidth: 10, belowBarData: BarAreaData( @@ -195,7 +195,7 @@ class LineChartSample6 extends StatelessWidget { ], ), spots: reverseSpots(spots2, minSpotY, maxSpotY), - isCurved: true, + curve: const LineChartCubicTensionCurve(), isStrokeCapRound: true, barWidth: 10, belowBarData: BarAreaData( diff --git a/example/lib/presentation/samples/line/line_chart_sample7.dart b/example/lib/presentation/samples/line/line_chart_sample7.dart index 16f44d6fd..8c451785a 100644 --- a/example/lib/presentation/samples/line/line_chart_sample7.dart +++ b/example/lib/presentation/samples/line/line_chart_sample7.dart @@ -87,7 +87,7 @@ class LineChartSample7 extends StatelessWidget { FlSpot(10, 6), FlSpot(11, 7), ], - isCurved: true, + curve: const LineChartCubicTensionCurve(), barWidth: 2, color: line1Color, dotData: const FlDotData( @@ -109,7 +109,7 @@ class LineChartSample7 extends StatelessWidget { FlSpot(10, 1), FlSpot(11, 3), ], - isCurved: false, + curve: LineChartCurve.noCurve, barWidth: 2, color: line2Color, dotData: const FlDotData( diff --git a/example/lib/presentation/samples/line/line_chart_sample8.dart b/example/lib/presentation/samples/line/line_chart_sample8.dart index 0a0383b45..7e801c17e 100644 --- a/example/lib/presentation/samples/line/line_chart_sample8.dart +++ b/example/lib/presentation/samples/line/line_chart_sample8.dart @@ -277,7 +277,7 @@ class _LineChartSample8State extends State { FlSpot(11, 2.5), ], dashArray: [10, 6], - isCurved: true, + curve: const LineChartCubicTensionCurve(), color: AppColors.contentColorRed, barWidth: 4, isStrokeCapRound: true, diff --git a/example/lib/presentation/samples/line/line_chart_sample9.dart b/example/lib/presentation/samples/line/line_chart_sample9.dart index 7143fbd28..1dccfad56 100644 --- a/example/lib/presentation/samples/line/line_chart_sample9.dart +++ b/example/lib/presentation/samples/line/line_chart_sample9.dart @@ -82,7 +82,7 @@ class LineChartSample9 extends StatelessWidget { LineChartBarData( color: AppColors.contentColorPink, spots: spots, - isCurved: true, + curve: const LineChartCubicTensionCurve(), isStrokeCapRound: true, barWidth: 3, belowBarData: BarAreaData( diff --git a/lib/fl_chart.dart b/lib/fl_chart.dart index efc2e1e9e..c2b74eecc 100644 --- a/lib/fl_chart.dart +++ b/lib/fl_chart.dart @@ -9,6 +9,7 @@ export 'src/chart/base/base_chart/fl_touch_event.dart'; export 'src/chart/candlestick_chart/candlestick_chart.dart'; export 'src/chart/candlestick_chart/candlestick_chart_data.dart'; export 'src/chart/line_chart/line_chart.dart'; +export 'src/chart/line_chart/line_chart_curve.dart'; export 'src/chart/line_chart/line_chart_data.dart'; export 'src/chart/pie_chart/pie_chart.dart'; export 'src/chart/pie_chart/pie_chart_data.dart'; diff --git a/lib/src/chart/line_chart/line_chart_curve.dart b/lib/src/chart/line_chart/line_chart_curve.dart new file mode 100644 index 000000000..7a98c7e83 --- /dev/null +++ b/lib/src/chart/line_chart/line_chart_curve.dart @@ -0,0 +1,346 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:equatable/equatable.dart'; + +LineChartCurve lerpCurve(LineChartCurve a, LineChartCurve b, double t) { + // Align curve types + (a, b) = switch ((a, b)) { + (final LineChartNoCurve _, _) => (b.noCurveCase, b), + (_, final LineChartNoCurve _) => (a, a.noCurveCase), + (_, _) => (a, b), + }; + + if (a.runtimeType == b.runtimeType) { + return a.lerp(b, t) as LineChartCurve; + } + + return b; +} + +abstract class LineChartCurve> with EquatableMixin { + const LineChartCurve(); + + static const noCurve = LineChartNoCurve(); + + static LineChartCubicTensionCurve cubicTension({ + double smoothness = 0.35, + bool preventCurveOverShooting = false, + double preventCurveOvershootingThreshold = 10.0, + }) => + LineChartCubicTensionCurve( + smoothness: smoothness, + preventCurveOverShooting: preventCurveOverShooting, + preventCurveOvershootingThreshold: preventCurveOvershootingThreshold, + ); + + static LineChartCubicMonotoneCurve cubicMonotone({ + double smooth = 0.5, + SmoothMonotone monotone = SmoothMonotone.none, + double tinyThresholdSquared = 0.5, + }) => + LineChartCubicMonotoneCurve( + smooth: smooth, + monotone: monotone, + tinyThresholdSquared: tinyThresholdSquared, + ); + + /// Returns a linear-equivalent configuration of this curve for lerping. + /// When interpolating with a "no curve" (straight line), this should be an + /// instance of the same curve type configured to behave as a straight line. + /// If your curve becomes linear at a specific parameter value (e.g. smoothness = 0), + /// return that configuration; otherwise, return `this`. + /// + // ignore: avoid_returning_this + LineChartCurve get noCurveCase => this; + + /// Appends the segment from [previous] to [current] into the [path]. + /// Implementations may use [next] to compute control points for smoothing. + /// + /// Note: Iteration starts from the second data point. The first point is + /// already handled internally (e.g. moved to the path), so this method is + /// called beginning at index 1. When [current] is the last point, [next] + /// is `null`. + void appendToPath(Path path, Offset previous, Offset current, Offset? next); + + T lerp(covariant T other, double t); +} + +class LineChartNoCurve extends LineChartCurve { + const LineChartNoCurve(); + + @override + void appendToPath(Path path, Offset previous, Offset current, Offset? next) { + path.lineTo(current.dx, current.dy); + } + + @override + LineChartNoCurve lerp(LineChartNoCurve other, double t) => other; + + @override + List get props => const []; +} + +class LineChartCubicTensionCurve + extends LineChartCurve with EquatableMixin { + const LineChartCubicTensionCurve({ + this.smoothness = 0.35, + this.preventCurveOverShooting = false, + this.preventCurveOvershootingThreshold = 10.0, + }); + + /// It determines smoothness of the curved edges. + final double smoothness; + + /// Prevent overshooting when draw curve line with high value changes. + /// check this [issue](https://github.com/imaNNeo/fl_chart/issues/25) + final double preventCurveOvershootingThreshold; + + /// Applies threshold for [preventCurveOverShooting] algorithm. + final bool preventCurveOverShooting; + + @override + LineChartCubicTensionCurve get noCurveCase => LineChartCubicTensionCurve( + smoothness: 0, + preventCurveOverShooting: preventCurveOverShooting, + preventCurveOvershootingThreshold: preventCurveOvershootingThreshold, + ); + + static Offset _flag = Offset.zero; + + @override + void appendToPath(Path path, Offset previous, Offset current, Offset? next) { + final resolvedNext = next ?? current; + + final controlPoint1 = previous + _flag; + + _flag = ((resolvedNext - previous) / 2) * smoothness; + + if (preventCurveOverShooting) { + if ((resolvedNext - current).dy <= preventCurveOvershootingThreshold || + (current - previous).dy <= preventCurveOvershootingThreshold) { + _flag = Offset(_flag.dx, 0); + } + + if ((resolvedNext - current).dx <= preventCurveOvershootingThreshold || + (current - previous).dx <= preventCurveOvershootingThreshold) { + _flag = Offset(0, _flag.dy); + } + } + + final controlPoint2 = current - _flag; + + path.cubicTo( + controlPoint1.dx, + controlPoint1.dy, + controlPoint2.dx, + controlPoint2.dy, + current.dx, + current.dy, + ); + + // reset flag when have no next point + if (next == null) { + _flag = Offset.zero; + } + } + + @override + LineChartCubicTensionCurve lerp(LineChartCubicTensionCurve other, double t) => + LineChartCubicTensionCurve( + smoothness: lerpDouble(smoothness, other.smoothness, t)!, + preventCurveOverShooting: other.preventCurveOverShooting, + preventCurveOvershootingThreshold: lerpDouble( + preventCurveOvershootingThreshold, + other.preventCurveOvershootingThreshold, + t, + )!, + ); + + LineChartCubicTensionCurve copyWith({ + double? smoothness, + bool? preventCurveOverShooting, + double? preventCurveOvershootingThreshold, + }) => + LineChartCubicTensionCurve( + smoothness: smoothness ?? this.smoothness, + preventCurveOverShooting: + preventCurveOverShooting ?? this.preventCurveOverShooting, + preventCurveOvershootingThreshold: preventCurveOvershootingThreshold ?? + this.preventCurveOvershootingThreshold, + ); + + @override + List get props => [ + smoothness, + preventCurveOverShooting, + preventCurveOvershootingThreshold, + ]; +} + +enum SmoothMonotone { none, x, y } + +/// Source: +/// https://github.com/apache/echarts/blob/513918064ac2a0866433d434dc969220f12b9c1a/src/chart/line/poly.ts#L39 +/// Copied from the ECharts implementation. +class LineChartCubicMonotoneCurve + extends LineChartCurve { + const LineChartCubicMonotoneCurve({ + this.smooth = 0.5, + this.monotone = SmoothMonotone.none, + this.tinyThresholdSquared = 0.5, + }); + + /// Smoothing factor controlling how rounded the curve is. + /// 0 draws straight segments; 1 yields the roundest result. + /// Used to scale cubic control points between data vertices. + final double smooth; + + /// Optional monotonicity constraint along a single axis. + /// Keeps the curve from overshooting in the selected axis (`x` or `y`). + /// `none` uses the general length-weighted method without constraints. + final SmoothMonotone monotone; + + /// Squared distance threshold to treat adjacent points as identical. + /// If (dx^2 + dy^2) is below this, a straight line is drawn to avoid jitter. + /// Unit: logical pixels squared. + final double tinyThresholdSquared; + + @override + LineChartCubicMonotoneCurve get noCurveCase => LineChartCubicMonotoneCurve( + smooth: 0, + monotone: monotone, + tinyThresholdSquared: tinyThresholdSquared, + ); + + static Offset? _flag; + + @override + void appendToPath(Path path, Offset previous, Offset current, Offset? next) { + if (smooth <= 0) { + path.lineTo(current.dx, current.dy); + _flag = current; + return; + } + + final cp0Init = _flag ?? previous; + final cpx0 = cp0Init.dx; + final cpy0 = cp0Init.dy; + + final dx = current.dx - previous.dx; + final dy = current.dy - previous.dy; + if (dx * dx + dy * dy < tinyThresholdSquared) { + path.lineTo(current.dx, current.dy); + return; + } + + double cpx1; + double cpy1; + double nextCpx0; + double nextCpy0; + + if (next == null) { + cpx1 = current.dx; + cpy1 = current.dy; + nextCpx0 = current.dx; + nextCpy0 = current.dy; + } else { + var vx = next.dx - previous.dx; + var vy = next.dy - previous.dy; + final dx0 = current.dx - previous.dx; + final dy0 = current.dy - previous.dy; + final dx1 = next.dx - current.dx; + final dy1 = next.dy - current.dy; + + if (monotone == SmoothMonotone.x) { + final lenPrevSeg = dx0.abs(); + final lenNextSeg = dx1.abs(); + final dir = vx > 0 ? 1.0 : -1.0; + cpx1 = current.dx - dir * lenPrevSeg * smooth; + cpy1 = current.dy; + nextCpx0 = current.dx + dir * lenNextSeg * smooth; + nextCpy0 = current.dy; + } else if (monotone == SmoothMonotone.y) { + final lenPrevSeg = dy0.abs(); + final lenNextSeg = dy1.abs(); + final dir = vy > 0 ? 1.0 : -1.0; + cpx1 = current.dx; + cpy1 = current.dy - dir * lenPrevSeg * smooth; + nextCpx0 = current.dx; + nextCpy0 = current.dy + dir * lenNextSeg * smooth; + } else { + final lenPrevSeg = sqrt(dx0 * dx0 + dy0 * dy0); + final lenNextSeg = sqrt(dx1 * dx1 + dy1 * dy1); + if (lenPrevSeg == 0 || lenNextSeg == 0) { + path.lineTo(current.dx, current.dy); + _flag = current; + return; + } + + final ratioNextSeg = lenNextSeg / (lenNextSeg + lenPrevSeg); + + cpx1 = current.dx - vx * smooth * (1 - ratioNextSeg); + cpy1 = current.dy - vy * smooth * (1 - ratioNextSeg); + + nextCpx0 = current.dx + vx * smooth * ratioNextSeg; + nextCpy0 = current.dy + vy * smooth * ratioNextSeg; + + final double minX = min(next.dx, current.dx); + final double maxX = max(next.dx, current.dx); + final double minY = min(next.dy, current.dy); + final double maxY = max(next.dy, current.dy); + nextCpx0 = min(nextCpx0, maxX); + nextCpx0 = max(nextCpx0, minX); + nextCpy0 = min(nextCpy0, maxY); + nextCpy0 = max(nextCpy0, minY); + + vx = nextCpx0 - current.dx; + vy = nextCpy0 - current.dy; + cpx1 = current.dx - vx * lenPrevSeg / lenNextSeg; + cpy1 = current.dy - vy * lenPrevSeg / lenNextSeg; + + final double minPX = min(previous.dx, current.dx); + final double maxPX = max(previous.dx, current.dx); + final double minPY = min(previous.dy, current.dy); + final double maxPY = max(previous.dy, current.dy); + cpx1 = min(cpx1, maxPX); + cpx1 = max(cpx1, minPX); + cpy1 = min(cpy1, maxPY); + cpy1 = max(cpy1, minPY); + + final ax = current.dx - cpx1; + final ay = current.dy - cpy1; + nextCpx0 = current.dx + ax * lenNextSeg / lenPrevSeg; + nextCpy0 = current.dy + ay * lenNextSeg / lenPrevSeg; + } + } + + path.cubicTo(cpx0, cpy0, cpx1, cpy1, current.dx, current.dy); + + // reset flag when have no next point + if (next == null) { + _flag = null; + } else { + _flag = Offset(nextCpx0, nextCpy0); + } + } + + @override + LineChartCubicMonotoneCurve lerp( + covariant LineChartCubicMonotoneCurve other, + double t, + ) => + LineChartCubicMonotoneCurve( + smooth: lerpDouble(smooth, other.smooth, t)!, + monotone: other.monotone, + tinyThresholdSquared: + lerpDouble(tinyThresholdSquared, other.tinyThresholdSquared, t)!, + ); + + @override + List get props => [ + smooth, + monotone, + tinyThresholdSquared, + ]; +} diff --git a/lib/src/chart/line_chart/line_chart_data.dart b/lib/src/chart/line_chart/line_chart_data.dart index 22ee479f5..13ca3c097 100644 --- a/lib/src/chart/line_chart/line_chart_data.dart +++ b/lib/src/chart/line_chart/line_chart_data.dart @@ -198,8 +198,8 @@ class LineChartBarData with EquatableMixin { /// [BarChart] draws some lines and overlaps them in the chart's view, /// You can have multiple lines by splitting them, /// put a [FlSpot.nullSpot] between each section. - /// each line passes through [spots], with hard edges by default, - /// [isCurved] makes it curve for drawing, and [curveSmoothness] determines the curve smoothness. + /// each line passes through [spots], with hard edges by default. + /// Use [curve] to control how segments are drawn (straight or smoothed). /// /// [show] determines the drawing, if set to false, it draws nothing. /// @@ -243,10 +243,20 @@ class LineChartBarData with EquatableMixin { this.gradient, this.gradientArea = LineChartGradientArea.rectAroundTheLine, this.barWidth = 2.0, - this.isCurved = false, - this.curveSmoothness = 0.35, - this.preventCurveOverShooting = false, - this.preventCurveOvershootingThreshold = 10.0, + LineChartCurve? curve, + @Deprecated('Use curve instead') bool isCurved = false, + @Deprecated( + 'Use LineChartCubicTensionCurve.smoothness instead, or try other curve types', + ) + double curveSmoothness = 0.35, + @Deprecated( + 'Use LineChartCubicTensionCurve.preventCurveOverShooting instead, or try other curve types', + ) + bool preventCurveOverShooting = false, + @Deprecated( + 'Use LineChartCubicTensionCurve.preventCurveOvershootingThreshold instead, or try other curve types', + ) + double preventCurveOvershootingThreshold = 10.0, this.isStrokeCapRound = false, this.isStrokeJoinRound = false, BarAreaData? belowBarData, @@ -262,7 +272,14 @@ class LineChartBarData with EquatableMixin { }) : color = color ?? ((color == null && gradient == null) ? Colors.cyan : null), belowBarData = belowBarData ?? BarAreaData(), - aboveBarData = aboveBarData ?? BarAreaData() { + aboveBarData = aboveBarData ?? BarAreaData(), + curve = curve ?? + _resovleCurve( + isCurved, + curveSmoothness, + preventCurveOverShooting, + preventCurveOvershootingThreshold, + ) { FlSpot? mostLeft; FlSpot? mostTop; FlSpot? mostRight; @@ -303,6 +320,21 @@ class LineChartBarData with EquatableMixin { } } + static LineChartCurve _resovleCurve( + bool isCurved, + double curveSmoothness, + bool preventCurveOverShooting, + double preventCurveOvershootingThreshold, + ) => + isCurved + ? LineChartCubicTensionCurve( + smoothness: curveSmoothness, + preventCurveOverShooting: preventCurveOverShooting, + preventCurveOvershootingThreshold: + preventCurveOvershootingThreshold, + ) + : LineChartCurve.noCurve; + /// This line goes through this spots. /// /// You can have multiple lines by splitting them, @@ -342,19 +374,11 @@ class LineChartBarData with EquatableMixin { /// Determines thickness of drawing line. final double barWidth; - /// If it's true, [LineChart] draws the line with curved edges, - /// otherwise it draws line with hard edges. - final bool isCurved; - - /// If [isCurved] is true, it determines smoothness of the curved edges. - final double curveSmoothness; - - /// Prevent overshooting when draw curve line with high value changes. - /// check this [issue](https://github.com/imaNNeo/fl_chart/issues/25) - final bool preventCurveOverShooting; - - /// Applies threshold for [preventCurveOverShooting] algorithm. - final double preventCurveOvershootingThreshold; + /// Curve strategy for drawing segments between spots. + /// Use a built-in curve (e.g. [LineChartCurve.noCurve], + /// [LineChartCubicTensionCurve], [LineChartCubicMonotoneCurve]) + /// or provide your own implementation of [LineChartCurve]. + final LineChartCurve curve; /// Determines the style of line's cap. final bool isStrokeCapRound; @@ -401,16 +425,9 @@ class LineChartBarData with EquatableMixin { barWidth: lerpDouble(a.barWidth, b.barWidth, t)!, belowBarData: BarAreaData.lerp(a.belowBarData, b.belowBarData, t), aboveBarData: BarAreaData.lerp(a.aboveBarData, b.aboveBarData, t), - curveSmoothness: b.curveSmoothness, - isCurved: b.isCurved, isStrokeCapRound: b.isStrokeCapRound, isStrokeJoinRound: b.isStrokeJoinRound, - preventCurveOverShooting: b.preventCurveOverShooting, - preventCurveOvershootingThreshold: lerpDouble( - a.preventCurveOvershootingThreshold, - b.preventCurveOvershootingThreshold, - t, - )!, + curve: lerpCurve(a.curve, b.curve, t), dotData: FlDotData.lerp(a.dotData, b.dotData, t), errorIndicatorData: FlErrorIndicatorData.lerp( a.errorIndicatorData, @@ -438,9 +455,19 @@ class LineChartBarData with EquatableMixin { Gradient? gradient, LineChartGradientArea? gradientArea, double? barWidth, - bool? isCurved, + LineChartCurve? curve, + @Deprecated('Use curve instead') bool? isCurved, + @Deprecated( + 'Use LineChartCubicTensionCurve.smoothness instead, or try other curve types', + ) double? curveSmoothness, + @Deprecated( + 'Use LineChartCubicTensionCurve.preventCurveOverShooting instead, or try other curve types', + ) bool? preventCurveOverShooting, + @Deprecated( + 'Use LineChartCubicTensionCurve.preventCurveOvershootingThreshold instead, or try other curve types', + ) double? preventCurveOvershootingThreshold, bool? isStrokeCapRound, bool? isStrokeJoinRound, @@ -462,12 +489,14 @@ class LineChartBarData with EquatableMixin { gradient: gradient ?? this.gradient, gradientArea: gradientArea ?? this.gradientArea, barWidth: barWidth ?? this.barWidth, - isCurved: isCurved ?? this.isCurved, - curveSmoothness: curveSmoothness ?? this.curveSmoothness, - preventCurveOverShooting: - preventCurveOverShooting ?? this.preventCurveOverShooting, - preventCurveOvershootingThreshold: preventCurveOvershootingThreshold ?? - this.preventCurveOvershootingThreshold, + curve: curve ?? + _resolveCopyWithCurve( + isCurved, + curveSmoothness, + preventCurveOverShooting, + preventCurveOvershootingThreshold, + ) ?? + this.curve, isStrokeCapRound: isStrokeCapRound ?? this.isStrokeCapRound, isStrokeJoinRound: isStrokeJoinRound ?? this.isStrokeJoinRound, belowBarData: belowBarData ?? this.belowBarData, @@ -481,6 +510,32 @@ class LineChartBarData with EquatableMixin { lineChartStepData: lineChartStepData ?? this.lineChartStepData, ); + LineChartCurve? _resolveCopyWithCurve( + bool? isCurved, + double? curveSmoothness, + bool? preventCurveOverShooting, + double? preventCurveOvershootingThreshold, + ) { + if (isCurved == null) { + return switch (curve) { + final LineChartCubicTensionCurve cubicCurve => cubicCurve.copyWith( + smoothness: curveSmoothness, + preventCurveOverShooting: preventCurveOverShooting, + preventCurveOvershootingThreshold: + preventCurveOvershootingThreshold, + ), + final LineChartNoCurve noCurve => noCurve, + _ => null, + }; + } + + if (isCurved) { + return const LineChartCubicTensionCurve(); + } else { + return const LineChartNoCurve(); + } + } + /// Used for equality check, see [EquatableMixin]. @override List get props => [ @@ -490,10 +545,7 @@ class LineChartBarData with EquatableMixin { gradient, gradientArea, barWidth, - isCurved, - curveSmoothness, - preventCurveOverShooting, - preventCurveOvershootingThreshold, + curve, isStrokeCapRound, isStrokeJoinRound, belowBarData, diff --git a/lib/src/chart/line_chart/line_chart_painter.dart b/lib/src/chart/line_chart/line_chart_painter.dart index 6f47aee2d..0d626a659 100644 --- a/lib/src/chart/line_chart/line_chart_painter.dart +++ b/lib/src/chart/line_chart/line_chart_painter.dart @@ -572,8 +572,6 @@ class LineChartPainter extends AxisChartPainter { final path = appendToPath ?? Path(); final size = barSpots.length; - var temp = Offset.zero; - final x = getPixelX(barSpots[0].x, viewSize, holder); final y = getPixelY(barSpots[0].y, viewSize, holder); if (appendToPath == null) { @@ -598,43 +596,14 @@ class LineChartPainter extends AxisChartPainter { ); /// next point - final next = Offset( - getPixelX(barSpots[i + 1 < size ? i + 1 : i].x, viewSize, holder), - getPixelY(barSpots[i + 1 < size ? i + 1 : i].y, viewSize, holder), - ); - - final controlPoint1 = previous + temp; - - /// if the isCurved is false, we set 0 for smoothness, - /// it means we should not have any smoothness then we face with - /// the sharped corners line - final smoothness = barData.isCurved ? barData.curveSmoothness : 0.0; - temp = ((next - previous) / 2) * smoothness; - - if (barData.preventCurveOverShooting) { - if ((next - current).dy <= barData.preventCurveOvershootingThreshold || - (current - previous).dy <= - barData.preventCurveOvershootingThreshold) { - temp = Offset(temp.dx, 0); - } - - if ((next - current).dx <= barData.preventCurveOvershootingThreshold || - (current - previous).dx <= - barData.preventCurveOvershootingThreshold) { - temp = Offset(0, temp.dy); - } - } - - final controlPoint2 = current - temp; + final next = i < size - 1 + ? Offset( + getPixelX(barSpots[i + 1].x, viewSize, holder), + getPixelY(barSpots[i + 1].y, viewSize, holder), + ) + : null; - path.cubicTo( - controlPoint1.dx, - controlPoint1.dy, - controlPoint2.dx, - controlPoint2.dy, - current.dx, - current.dy, - ); + barData.curve.appendToPath(path, previous, current, next); } return path; diff --git a/repo_files/documentations/line_chart.md b/repo_files/documentations/line_chart.md index d8f30a79d..45990c647 100644 --- a/repo_files/documentations/line_chart.md +++ b/repo_files/documentations/line_chart.md @@ -47,10 +47,7 @@ When you change the chart's state, it animates to the new state internally (usin |gradient| You can use any [Gradient](https://api.flutter.dev/flutter/dart-ui/Gradient-class.html) here. such as [LinearGradient](https://api.flutter.dev/flutter/painting/LinearGradient-class.html) or [RadialGradient](https://api.flutter.dev/flutter/painting/RadialGradient-class.html)|null| |gradientArea| determines the area where the gradient is applied |null| |barWidth| gets the stroke width of the line bar|2.0| -|isCurved| curves the corners of the line on the spot's positions| false| -|curveSmoothness| smoothness radius of the curve corners (works when isCurved is true) | 0.35| -|preventCurveOverShooting|prevent overshooting when draw curve line on linear sequence spots, check this [issue](https://github.com/imaNNeo/fl_chart/issues/25)| false| -|preventCurveOvershootingThreshold|threshold for applying prevent overshooting algorithm | 10.0| +|curve| determines the curve style for drawing segments between spots (use [LineChartCurve](#LineChartCurve) types)| LineChartCurve.noCurve (straight lines)| |isStrokeCapRound| determines whether start and end of the bar line is Qubic or Round | false| |isStrokeJoinRound| determines whether stroke joins have a round shape or a sharp edge | false| |belowBarData| check the [BarAreaData](#BarAreaData) |BarAreaData| @@ -63,6 +60,21 @@ When you change the chart's state, it animates to the new state internally (usin |lineChartStepData|Holds data for representing a Step Line Chart, and works only if [isStepChart] is true.|[LineChartStepData](#LineChartStepData)()| |errorIndicatorData|Holds data for representing an error indicator (you see the error indicators if you provide the `xError` or `yError` in the [FlSpot](base_chart.md#FlSpot)).|[ErrorIndicatorData()](base_chart.md#FlErrorIndicatorData)| +### LineChartCurve +#### LineChartCubicTensionCurve +|PropName|Description|default value| +|:-------|:----------|:------------| +|smoothness| smoothness radius of the curve corners | 0.35| +|preventCurveOverShooting|prevent overshooting when draw curve line on linear sequence spots, check this [issue](https://github.com/imaNNeo/fl_chart/issues/25)| false| +|preventCurveOvershootingThreshold|threshold for applying prevent overshooting algorithm | 10.0| + +#### LineChartCubicMonotoneCurve +|PropName|Description|default value| +|:-------|:----------|:------------| +|smooth| determines smoothness of the curve (0.0 = straight, 1.0 = roundest) | 0.5| +|monotone| monotonicity constraint (none, x, or y) to prevent overshooting along the specified axis | SmoothMonotone.none| +|tinyThresholdSquared| squared distance threshold to draw straight line when points are too close (avoids jitter) | 0.5| + ### LineChartStepData |PropName|Description|default value| |:-------|:----------|:------------| diff --git a/test/chart/data_pool.dart b/test/chart/data_pool.dart index a32c8a5c3..e3b8e8c6e 100644 --- a/test/chart/data_pool.dart +++ b/test/chart/data_pool.dart @@ -147,10 +147,12 @@ class MockData { aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -171,10 +173,12 @@ class MockData { aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 4], ); @@ -829,10 +833,12 @@ final LineChartBarData lineChartBarData1 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], errorIndicatorData: const FlErrorIndicatorData( show: false, @@ -854,10 +860,12 @@ final LineChartBarData lineChartBarData1Clone = LineChartBarData( aboveBarData: barAreaData1Clone, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1Clone, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], errorIndicatorData: const FlErrorIndicatorData( show: false, @@ -881,10 +889,12 @@ final LineChartBarData lineChartBarData2 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 4], ); @@ -905,10 +915,12 @@ final LineChartBarData lineChartBarData3 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -929,10 +941,12 @@ final LineChartBarData lineChartBarData4 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -951,10 +965,12 @@ final LineChartBarData lineChartBarData5 = LineChartBarData( aboveBarData: barAreaData2, belowBarData: barAreaData1, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -973,10 +989,12 @@ final LineChartBarData lineChartBarData6 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -995,10 +1013,12 @@ final LineChartBarData lineChartBarData7 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -1017,10 +1037,12 @@ final LineChartBarData lineChartBarData8 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12.01, + curve: const LineChartCubicTensionCurve( + smoothness: 12.01, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); @@ -1039,11 +1061,13 @@ final LineChartBarData lineChartBarData9 = LineChartBarData( aboveBarData: barAreaData1, belowBarData: barAreaData2, barWidth: 12, - curveSmoothness: 12, + curve: const LineChartCubicTensionCurve( + smoothness: 12, + preventCurveOverShooting: true, + preventCurveOvershootingThreshold: 1.2, + ), dotData: flDotData1, isStrokeCapRound: true, - preventCurveOverShooting: true, - preventCurveOvershootingThreshold: 1.2, showingIndicators: [0, 1], ); diff --git a/test/chart/line_chart/line_chart_curve_test.dart b/test/chart/line_chart/line_chart_curve_test.dart new file mode 100644 index 000000000..d3e18cca2 --- /dev/null +++ b/test/chart/line_chart/line_chart_curve_test.dart @@ -0,0 +1,371 @@ +import 'dart:ui'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:fl_chart/src/chart/line_chart/line_chart_curve.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const testPoints1 = [ + Offset(10, 10), + Offset(20, 20), + Offset(30, 40), + Offset(40, 10), +]; + +void main() { + test('static constructor', () { + expect(LineChartCurve.noCurve, equals(const LineChartNoCurve())); + expect( + LineChartCurve.cubicTension(), + equals(const LineChartCubicTensionCurve()), + ); + expect( + LineChartCurve.cubicMonotone(), + equals(const LineChartCubicMonotoneCurve()), + ); + }); + + group('LineChartNoCurve', () { + test('equality check', () { + const a = LineChartNoCurve(); + const b = LineChartNoCurve(); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('no curve case returns self', () { + const curve = LineChartNoCurve(); + expect(curve.noCurveCase, equals(curve)); + }); + + test('lerp with no curve case returns self', () { + const curve = LineChartNoCurve(); + expect(curve.lerp(curve, 0.5), equals(curve)); + }); + + test('appendToPath draws straight line', () { + expectLikeStraightLine(const LineChartNoCurve()); + }); + }); + + group('LineChartCubicTensionCurve', () { + test('equality check', () { + const a = LineChartCubicTensionCurve(); + const b = LineChartCubicTensionCurve(); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('lerp no curve', () { + const curve = LineChartCubicTensionCurve(smoothness: 0.8); + + expect( + lerpCurve(curve, const LineChartNoCurve(), 0.5), + equals(const LineChartCubicTensionCurve(smoothness: 0.4)), + ); + }); + + test('CubicTensionCurve with smoothness = 0 behaves like straight line', + () { + expectLikeStraightLine(const LineChartCubicTensionCurve(smoothness: 0)); + }); + + test( + 'prevents overshoot when dy difference is below threshold in y direction', + () { + const curve = LineChartCubicTensionCurve( + preventCurveOverShooting: true, + ); + + // Create points where dy difference is below threshold (5 < 10) + final points = [ + Offset.zero, + const Offset(20, 5), // dy = 5 + const Offset(40, 8), // dy = 3 + ]; + + final path = _buildPathWithCurve(points, curve); + + final metrics = path.computeMetrics().toList(); + expect(metrics.length, 1); + }); + + test( + 'prevents overshoot when dx difference is below threshold in x direction', + () { + const curve = LineChartCubicTensionCurve( + preventCurveOverShooting: true, + ); + + // Create points where dx difference is below threshold (5 < 10) + final points = [ + Offset.zero, + const Offset(5, 20), // dx = 5 + const Offset(8, 40), // dx = 3 + ]; + + final path = _buildPathWithCurve(points, curve); + + final metrics = path.computeMetrics().toList(); + expect(metrics.length, 1); + }); + }); + + group('LineChartCubicMonotoneCurve', () { + test('equality check', () { + const a = LineChartCubicMonotoneCurve(); + const b = LineChartCubicMonotoneCurve(); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('lerp no curve', () { + const curve = LineChartCubicMonotoneCurve(smooth: 0.8); + + expect( + lerpCurve(const LineChartNoCurve(), curve, 0.5), + equals(const LineChartCubicMonotoneCurve(smooth: 0.4)), + ); + }); + + test('draw straight line if just two points', () { + expectLikeStraightLine( + const LineChartCubicMonotoneCurve(tinyThresholdSquared: 0), + points: [Offset.zero, const Offset(10, 10)], + ); + }); + + test('draw straight line if smooth = 0', () { + expectLikeStraightLine( + const LineChartCubicMonotoneCurve( + smooth: 0, + tinyThresholdSquared: double.infinity, + ), + ); + }); + + group('tinyThreshold parameter behavior', () { + test('draw straight line if distance < tinyThreshold', () { + expectLikeStraightLine( + const LineChartCubicMonotoneCurve( + tinyThresholdSquared: double.infinity, + ), + ); + }); + + test('draw curved line if distance > tinyThreshold', () { + final path = _buildPathWithCurve( + testPoints1, + const LineChartCubicMonotoneCurve(tinyThresholdSquared: 0), + ); + + final metrics = path.computeMetrics().toList(); + + expect( + metrics.single.length, + greaterThan(testPoints1.straightDistance), + ); + }); + }); + + group('smooth parameter behavior', () { + test('the effect of smooth increases monotonically', () { + var smoothCount = 1; + var lastCurveLength = testPoints1.straightDistance; + + while (smoothCount < 11) { + final smooth = 0.1 * smoothCount; + + final path = _buildPathWithCurve( + testPoints1, + LineChartCubicMonotoneCurve( + smooth: smooth, + tinyThresholdSquared: 0, + ), + ); + final curveLength = path.computeMetrics().single.length; + expect(curveLength, greaterThanOrEqualTo(lastCurveLength)); + lastCurveLength = curveLength; + smoothCount++; + } + }); + }); + + group('monotone constraint behavior', () { + test('SmoothMonotone.x prevents Y-direction overshoot with zigzag data', + () { + final points = [ + const Offset(0, 15), + const Offset(10, -50), + const Offset(20, -56.5), + const Offset(30, -46.5), + const Offset(40, -22.1), + const Offset(50, -2.5), + ]; + + const curve = LineChartCubicMonotoneCurve( + monotone: SmoothMonotone.x, + smooth: 0.3, + tinyThresholdSquared: 0, + ); + + final path = _buildPathWithCurve(points, curve); + final samples = _samplePath(path, 100); + + // Verify that Y coordinates of each curve segment stay within + // the Y range of its two endpoints + for (var i = 0; i < points.length - 1; i++) { + final minY = + points[i].dy < points[i + 1].dy ? points[i].dy : points[i + 1].dy; + final maxY = + points[i].dy > points[i + 1].dy ? points[i].dy : points[i + 1].dy; + + final segmentSamples = samples + .where((s) => s.dx >= points[i].dx && s.dx <= points[i + 1].dx); + + for (final sample in segmentSamples) { + expect( + sample.dy, + inRange(minY - 1.0, maxY + 1.0), + reason: 'Segment $i: Y=${sample.dy} out of range [$minY, $maxY]', + ); + } + } + }); + + test( + 'SmoothMonotone.y prevents X-direction overshoot with horizontal zigzag', + () { + final points = [ + const Offset(50, 0), + const Offset(10, 10), + const Offset(90, 20), + const Offset(20, 30), + const Offset(80, 40), + ]; + + const curve = LineChartCubicMonotoneCurve( + monotone: SmoothMonotone.y, + smooth: 0.3, + tinyThresholdSquared: 0, + ); + + final path = _buildPathWithCurve(points, curve); + final samples = _samplePath(path, 100); + + // Verify that X coordinates of each curve segment stay within + // the X range of its two endpoints + for (var i = 0; i < points.length - 1; i++) { + final minX = + points[i].dx < points[i + 1].dx ? points[i].dx : points[i + 1].dx; + final maxX = + points[i].dx > points[i + 1].dx ? points[i].dx : points[i + 1].dx; + + final segmentSamples = samples + .where((s) => s.dy >= points[i].dy && s.dy <= points[i + 1].dy); + + for (final sample in segmentSamples) { + expect( + sample.dx, + inRange(minX - 1.0, maxX + 1.0), + reason: 'Segment $i: X=${sample.dx} out of range [$minX, $maxX]', + ); + } + } + }); + }); + }); +} + +/// Sample points uniformly along the path +List _samplePath(Path path, int sampleCount) { + final samples = []; + final metrics = path.computeMetrics().first; + + for (var i = 0; i <= sampleCount; i++) { + final distance = metrics.length * i / sampleCount; + final tangent = metrics.getTangentForOffset(distance); + if (tangent != null) { + samples.add(tangent.position); + } + } + + return samples; +} + +/// Custom range matcher +Matcher inRange(num min, num max) => _InRangeMatcher(min, max); + +class _InRangeMatcher extends Matcher { + const _InRangeMatcher(this.min, this.max); + + final num min; + final num max; + + @override + bool matches(dynamic item, Map matchState) { + if (item is! num) return false; + return item >= min && item <= max; + } + + @override + Description describe(Description description) { + return description.add('in range [$min, $max]'); + } + + @override + Description describeMismatch( + dynamic item, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + return mismatchDescription.add('was $item, outside range [$min, $max]'); + } +} + +void expectLikeStraightLine( + LineChartCurve curve, { + List points = testPoints1, +}) { + final path = _buildPathWithCurve(points, curve); + + final metrics = path.computeMetrics().toList(); + expect(metrics.single.length, closeTo(points.straightDistance, 0.001)); +} + +/// Helper function to build a complete path with the given curve +Path _buildPathWithCurve(List points, LineChartCurve curve) { + final path = Path(); + if (points.isEmpty) { + return path; + } + + path.moveTo(points[0].dx, points[0].dy); + + for (var i = 1; i < points.length; i++) { + final previous = points[i - 1]; + final current = points[i]; + final next = i < points.length - 1 ? points[i + 1] : null; + + curve.appendToPath(path, previous, current, next); + } + + return path; +} + +extension on Offset { + double distanceTo(Offset other) => (this - other).distance; +} + +extension on List { + double get straightDistance { + double distance = 0; + for (var i = 1; i < length; i++) { + distance += this[i].distanceTo(this[i - 1]); + } + return distance; + } +}