From 436d51b768546505316b6608a0fcd7a5116e3cb9 Mon Sep 17 00:00:00 2001 From: Nitride <77973576+CharlVS@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:15:49 +0200 Subject: [PATCH 1/2] feat(dragon_charts_flutter): add configurable sparkline baseline --- packages/dragon_charts_flutter/CHANGELOG.md | 1 + .../lib/src/sparkline/sparkline_chart.dart | 139 ++++++++++-------- .../test/sparkline_chart_test.dart | 60 +++++++- 3 files changed, 132 insertions(+), 68 deletions(-) diff --git a/packages/dragon_charts_flutter/CHANGELOG.md b/packages/dragon_charts_flutter/CHANGELOG.md index f9540971..8f8a7f3b 100644 --- a/packages/dragon_charts_flutter/CHANGELOG.md +++ b/packages/dragon_charts_flutter/CHANGELOG.md @@ -3,6 +3,7 @@ > Note: This release has breaking changes. - **BREAKING** **CHORE**: unify Dart SDK (^3.9.0) and Flutter (>=3.35.0 <3.36.0) constraints across workspace. + - **FEAT**: allow sparkline charts to customize the y=0 baseline calculation, defaulting to the initial value. ## 0.1.1-dev.2 diff --git a/packages/dragon_charts_flutter/lib/src/sparkline/sparkline_chart.dart b/packages/dragon_charts_flutter/lib/src/sparkline/sparkline_chart.dart index f086d5fc..66400ca4 100644 --- a/packages/dragon_charts_flutter/lib/src/sparkline/sparkline_chart.dart +++ b/packages/dragon_charts_flutter/lib/src/sparkline/sparkline_chart.dart @@ -1,5 +1,27 @@ import 'package:flutter/material.dart'; +typedef SparklineBaselineCalculator = double Function(List data); + +class SparklineBaselines { + const SparklineBaselines._(); + + static double initialValue(List data) { + if (data.isEmpty) { + return 0; + } + + return data.first; + } + + static double average(List data) { + if (data.isEmpty) { + return 0; + } + + return data.reduce((a, b) => a + b) / data.length; + } +} + class SparklineChart extends StatelessWidget { const SparklineChart({ required this.data, @@ -7,14 +29,17 @@ class SparklineChart extends StatelessWidget { required this.negativeLineColor, required this.lineThickness, this.isCurved = false, + SparklineBaselineCalculator? baselineCalculator, super.key, - }); + }) : baselineCalculator = + baselineCalculator ?? SparklineBaselines.initialValue; final List data; final Color positiveLineColor; final Color negativeLineColor; final double lineThickness; final bool isCurved; + final SparklineBaselineCalculator baselineCalculator; @override Widget build(BuildContext context) { @@ -28,6 +53,7 @@ class SparklineChart extends StatelessWidget { negativeLineColor: negativeLineColor, lineThickness: lineThickness, isCurved: isCurved, + baselineCalculator: baselineCalculator, ), ); }, @@ -42,35 +68,29 @@ class _CustomSparklinePainter extends CustomPainter { required this.negativeLineColor, required this.lineThickness, required this.isCurved, - }) { - // Handle empty data - if (data.isEmpty) { - average = 0; - } else { - average = data.reduce((a, b) => a + b) / data.length; - } - } + required SparklineBaselineCalculator baselineCalculator, + }) : baseline = data.isEmpty ? 0 : baselineCalculator(data); final List data; final Color positiveLineColor; final Color negativeLineColor; final double lineThickness; final bool isCurved; - late double average; + final double baseline; @override void paint(Canvas canvas, Size size) { // Handle empty data if (data.isEmpty) return; - + // Handle single data point if (data.length == 1) { // Draw a horizontal line at the middle of the canvas final Paint paint = Paint() - ..color = data[0] >= 0 ? positiveLineColor : negativeLineColor + ..color = data[0] >= baseline ? positiveLineColor : negativeLineColor ..strokeWidth = lineThickness ..style = PaintingStyle.stroke; - + canvas.drawLine( Offset(0, size.height / 2), Offset(size.width, size.height / 2), @@ -78,19 +98,19 @@ class _CustomSparklinePainter extends CustomPainter { ); return; } - + final double dx = size.width / (data.length - 1); final double minValue = data.reduce((a, b) => a < b ? a : b); final double maxValue = data.reduce((a, b) => a > b ? a : b); - + // Handle case where all values are the same if (maxValue == minValue) { // Draw a horizontal line at the middle of the canvas final Paint paint = Paint() - ..color = data[0] >= average ? positiveLineColor : negativeLineColor + ..color = data[0] >= baseline ? positiveLineColor : negativeLineColor ..strokeWidth = lineThickness ..style = PaintingStyle.stroke; - + canvas.drawLine( Offset(0, size.height / 2), Offset(size.width, size.height / 2), @@ -98,14 +118,18 @@ class _CustomSparklinePainter extends CustomPainter { ); return; } - + final double scaleY = size.height / (maxValue - minValue); - final double yAvg = size.height - ((average - minValue) * scaleY); + final double clampedBaseline = baseline + .clamp(minValue, maxValue) + .toDouble(); + final double yBaseline = + size.height - ((clampedBaseline - minValue) * scaleY); final Path pathAbove = Path(); final Path pathBelow = Path(); - pathAbove.moveTo(0, yAvg); - pathBelow.moveTo(0, yAvg); + pathAbove.moveTo(0, yBaseline); + pathBelow.moveTo(0, yBaseline); Offset? prevPointAbove; Offset? prevPointBelow; @@ -115,18 +139,18 @@ class _CustomSparklinePainter extends CustomPainter { final y = size.height - ((data[i] - minValue) * scaleY); final currentPoint = Offset(x, y); - if (data[i] >= average) { - if (i > 0 && data[i - 1] < average) { + if (data[i] >= baseline) { + if (i > 0 && data[i - 1] < baseline) { final xPrev = (i - 1) * dx; // final yPrev = size.height - ((data[i - 1] - minValue) * scaleY); final intersectionX = - xPrev + (dx * (average - data[i - 1]) / (data[i] - data[i - 1])); + xPrev + (dx * (baseline - data[i - 1]) / (data[i] - data[i - 1])); pathBelow - ..lineTo(intersectionX, yAvg) - ..lineTo(intersectionX, yAvg); - pathAbove.moveTo(intersectionX, yAvg); - prevPointAbove = Offset(intersectionX, yAvg); + ..lineTo(intersectionX, yBaseline) + ..lineTo(intersectionX, yBaseline); + pathAbove.moveTo(intersectionX, yBaseline); + prevPointAbove = Offset(intersectionX, yBaseline); } if (isCurved && prevPointAbove != null) { @@ -152,17 +176,17 @@ class _CustomSparklinePainter extends CustomPainter { } prevPointAbove = currentPoint; } else { - if (i > 0 && data[i - 1] >= average) { + if (i > 0 && data[i - 1] >= baseline) { final xPrev = (i - 1) * dx; // final yPrev = size.height - ((data[i - 1] - minValue) * scaleY); final intersectionX = - xPrev + (dx * (average - data[i - 1]) / (data[i] - data[i - 1])); + xPrev + (dx * (baseline - data[i - 1]) / (data[i] - data[i - 1])); pathAbove - ..lineTo(intersectionX, yAvg) - ..lineTo(intersectionX, yAvg); - pathBelow.moveTo(intersectionX, yAvg); - prevPointBelow = Offset(intersectionX, yAvg); + ..lineTo(intersectionX, yBaseline) + ..lineTo(intersectionX, yBaseline); + pathBelow.moveTo(intersectionX, yBaseline); + prevPointBelow = Offset(intersectionX, yBaseline); } if (isCurved && prevPointBelow != null) { @@ -191,10 +215,10 @@ class _CustomSparklinePainter extends CustomPainter { } // Extend the path to the right edge of the canvas - if (data.last >= average) { - pathAbove.lineTo(size.width, yAvg); + if (data.last >= baseline) { + pathAbove.lineTo(size.width, yBaseline); } else { - pathBelow.lineTo(size.width, yAvg); + pathBelow.lineTo(size.width, yBaseline); } // Gradient Paints @@ -206,9 +230,7 @@ class _CustomSparklinePainter extends CustomPainter { ], begin: Alignment.topCenter, end: Alignment.bottomCenter, - ).createShader( - Rect.fromPoints(Offset.zero, Offset(0, size.height)), - ); + ).createShader(Rect.fromPoints(Offset.zero, Offset(0, size.height))); final Paint belowGradientPaint = Paint() ..shader = LinearGradient( @@ -218,9 +240,7 @@ class _CustomSparklinePainter extends CustomPainter { ], begin: Alignment.bottomCenter, end: Alignment.topCenter, - ).createShader( - Rect.fromPoints(Offset.zero, Offset(0, size.height)), - ); + ).createShader(Rect.fromPoints(Offset.zero, Offset(0, size.height))); // Draw the filled paths first canvas @@ -238,16 +258,15 @@ class _CustomSparklinePainter extends CustomPainter { final x2 = (i + 1) * dx; final y2 = size.height - ((data[i + 1] - minValue) * scaleY); - if (data[i] >= average && data[i + 1] >= average) { + if (data[i] >= baseline && data[i + 1] >= baseline) { linePaint.color = positiveLineColor; - } else if (data[i] < average && data[i + 1] < average) { + } else if (data[i] < baseline && data[i + 1] < baseline) { linePaint.color = negativeLineColor; } else { final intersectionX = - x1 + (dx * (average - data[i]) / (data[i + 1] - data[i])); - final yAvg = size.height - ((average - minValue) * scaleY); + x1 + (dx * (baseline - data[i]) / (data[i + 1] - data[i])); - if (data[i] >= average) { + if (data[i] >= baseline) { linePaint.color = positiveLineColor; if (isCurved) { canvas.drawPath( @@ -257,19 +276,19 @@ class _CustomSparklinePainter extends CustomPainter { (x1 + intersectionX) / 2, y1, (x1 + intersectionX) / 2, - yAvg, + yBaseline, intersectionX, - yAvg, + yBaseline, ), linePaint, ); linePaint.color = negativeLineColor; canvas.drawPath( Path() - ..moveTo(intersectionX, yAvg) + ..moveTo(intersectionX, yBaseline) ..cubicTo( (intersectionX + x2) / 2, - yAvg, + yBaseline, (intersectionX + x2) / 2, y2, x2, @@ -280,12 +299,12 @@ class _CustomSparklinePainter extends CustomPainter { } else { canvas.drawLine( Offset(x1, y1), - Offset(intersectionX, yAvg), + Offset(intersectionX, yBaseline), linePaint, ); linePaint.color = negativeLineColor; canvas.drawLine( - Offset(intersectionX, yAvg), + Offset(intersectionX, yBaseline), Offset(x2, y2), linePaint, ); @@ -300,19 +319,19 @@ class _CustomSparklinePainter extends CustomPainter { (x1 + intersectionX) / 2, y1, (x1 + intersectionX) / 2, - yAvg, + yBaseline, intersectionX, - yAvg, + yBaseline, ), linePaint, ); linePaint.color = positiveLineColor; canvas.drawPath( Path() - ..moveTo(intersectionX, yAvg) + ..moveTo(intersectionX, yBaseline) ..cubicTo( (intersectionX + x2) / 2, - yAvg, + yBaseline, (intersectionX + x2) / 2, y2, x2, @@ -323,12 +342,12 @@ class _CustomSparklinePainter extends CustomPainter { } else { canvas.drawLine( Offset(x1, y1), - Offset(intersectionX, yAvg), + Offset(intersectionX, yBaseline), linePaint, ); linePaint.color = positiveLineColor; canvas.drawLine( - Offset(intersectionX, yAvg), + Offset(intersectionX, yBaseline), Offset(x2, y2), linePaint, ); diff --git a/packages/dragon_charts_flutter/test/sparkline_chart_test.dart b/packages/dragon_charts_flutter/test/sparkline_chart_test.dart index 6122ce64..c0037c2d 100644 --- a/packages/dragon_charts_flutter/test/sparkline_chart_test.dart +++ b/packages/dragon_charts_flutter/test/sparkline_chart_test.dart @@ -3,9 +3,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + group('SparklineBaselines', () { + test('initialValue returns 0 for empty data', () { + expect(SparklineBaselines.initialValue(const []), equals(0)); + }); + + test('initialValue returns the first value', () { + expect(SparklineBaselines.initialValue(const [3, 5, 7]), equals(3)); + }); + + test('average returns 0 for empty data', () { + expect(SparklineBaselines.average(const []), equals(0)); + }); + + test('average returns the average of the values', () { + expect(SparklineBaselines.average(const [2, 4, 6]), equals(4)); + }); + }); + group('SparklineChart', () { - testWidgets('handles empty data without crashing', - (WidgetTester tester) async { + testWidgets('handles empty data without crashing', ( + WidgetTester tester, + ) async { await tester.pumpWidget( const MaterialApp( home: SizedBox( @@ -24,8 +43,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('handles single data point without crashing', - (WidgetTester tester) async { + testWidgets('handles single data point without crashing', ( + WidgetTester tester, + ) async { await tester.pumpWidget( const MaterialApp( home: SizedBox( @@ -44,8 +64,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('handles all same values without crashing', - (WidgetTester tester) async { + testWidgets('handles all same values without crashing', ( + WidgetTester tester, + ) async { await tester.pumpWidget( const MaterialApp( home: SizedBox( @@ -64,8 +85,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('handles negative values correctly', - (WidgetTester tester) async { + testWidgets('handles negative values correctly', ( + WidgetTester tester, + ) async { await tester.pumpWidget( const MaterialApp( home: SizedBox( @@ -104,6 +126,28 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('supports custom baseline calculator', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: SizedBox( + width: 200, + height: 100, + child: SparklineChart( + data: [3.0, 1.0, 4.0, 2.0], + positiveLineColor: Colors.green, + negativeLineColor: Colors.red, + lineThickness: 2, + baselineCalculator: SparklineBaselines.average, + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + testWidgets('handles zero values', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( From cade32ddf4783fc0849407900559654fb3f5c0a2 Mon Sep 17 00:00:00 2001 From: Nitride <77973576+CharlVS@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:18:09 +0200 Subject: [PATCH 2/2] Update packages/dragon_charts_flutter/CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/dragon_charts_flutter/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dragon_charts_flutter/CHANGELOG.md b/packages/dragon_charts_flutter/CHANGELOG.md index 8f8a7f3b..b6928b01 100644 --- a/packages/dragon_charts_flutter/CHANGELOG.md +++ b/packages/dragon_charts_flutter/CHANGELOG.md @@ -3,7 +3,7 @@ > Note: This release has breaking changes. - **BREAKING** **CHORE**: unify Dart SDK (^3.9.0) and Flutter (>=3.35.0 <3.36.0) constraints across workspace. - - **FEAT**: allow sparkline charts to customize the y=0 baseline calculation, defaulting to the initial value. + - **FEAT**: allow sparkline charts to customize the baseline calculation for positive/negative value classification, defaulting to the initial value. ## 0.1.1-dev.2